Skip to content

Commit 9ac8cfb

Browse files
bkudiessCopilotCopilotshanselman
authored
Surface accurate node mode and MCP-only states (#827)
* Node mode UI: surface MCP-only/connecting states and repair gateway-node gating Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MCP-only node status visibility Surface the local MCP-only node card even when no gateway/operator session exists, and make the reconnect-backoff test wait for server-side accept publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Keep MCP-only mode from joining gateways Gate the post-operator local NodeService auto-connect on EnableNodeMode so local MCP-only serving does not create gateway node pairing requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Request node reconnect in shared-token setup E2E The shared-token setup path can now remain MCP-only after operator approval, so the E2E needs to request node reconnect explicitly before waiting for a node credential. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Scott Hanselman <scott@hanselman.com>
1 parent f51a861 commit 9ac8cfb

15 files changed

Lines changed: 653 additions & 83 deletions

File tree

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands
6767
internal VoiceService? VoiceService => _nodeService?.VoiceService ?? _standaloneVoiceService;
6868
/// <summary>The full device ID of the local node service (if running).</summary>
6969
internal string? NodeFullDeviceId => _nodeService?.FullDeviceId;
70+
/// <summary>Live node service instance used by settings surfaces for MCP status.</summary>
71+
internal NodeService? ActiveNodeService => _nodeService;
7072

7173
/// <summary>
7274
/// Session key that the chat surface should select on its next mount.
@@ -644,7 +646,7 @@ _dispatcherQueue is null
644646
credentialResolver, clientFactory, _gatewayRegistry, appLogger,
645647
identityStore: new DeviceIdentityFileStore(appLogger),
646648
nodeConnector: nodeConnector,
647-
isNodeEnabled: ShouldInitializeNodeService,
649+
isNodeEnabled: IsGatewayNodeEnabled,
648650
diagnostics: diagnostics,
649651
tunnelManager: _sshTunnelService);
650652
_connectionManager.OperatorClientChanged += OnOperatorClientChanged;
@@ -1541,7 +1543,7 @@ record = SyncGatewayBrowserProxyForward(record);
15411543
if (credential == null)
15421544
{
15431545
var nodeCredential = ResolveStartupNodeCredential(record, resolver, identityDir);
1544-
if (nodeCredential != null && ShouldInitializeNodeService())
1546+
if (nodeCredential != null && IsGatewayNodeEnabled())
15451547
{
15461548
Logger.Info(
15471549
$"Connecting node-only gateway during {context}: {record.Url} ({nodeCredential.Source})");
@@ -1562,6 +1564,8 @@ record = SyncGatewayBrowserProxyForward(record);
15621564
ObserveBackgroundFault(
15631565
_connectionManager.ConnectAsync(record.Id),
15641566
$"[App] Startup gateway connect failed during {context}");
1567+
if (!IsGatewayNodeEnabled())
1568+
TryStartLocalMcpOnlyNode();
15651569
return true;
15661570
}
15671571

@@ -1891,6 +1895,12 @@ private bool ShouldInitializeNodeService()
18911895
return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true;
18921896
}
18931897

1898+
/// <summary>True when this PC should connect as a gateway node.</summary>
1899+
private bool IsGatewayNodeEnabled()
1900+
{
1901+
return _settings?.EnableNodeMode == true;
1902+
}
1903+
18941904
/// <summary>
18951905
/// Ensures a WSL keepalive process is running for the local gateway distro
18961906
/// so the WSL2 VM stays up even after the tray exits.
@@ -2672,8 +2682,8 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s
26722682
if (status == ConnectionStatus.Connected)
26732683
{
26742684
_ = RunHealthCheckAsync();
2675-
// For local gateways, the NodeConnector is suppressed because NodeService
2676-
// owns the identity. Connect the NodeService directly after operator connects.
2685+
// Gateway-node mode connects the NodeService after operator auth; MCP-only
2686+
// mode keeps serving local tools and must not escalate into node pairing.
26772687
_ = TryConnectLocalNodeServiceAsync();
26782688
}
26792689
}
@@ -2686,7 +2696,7 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s
26862696
/// </summary>
26872697
private async Task TryConnectLocalNodeServiceAsync()
26882698
{
2689-
if (_connectionManager == null)
2699+
if (_connectionManager == null || !IsGatewayNodeEnabled())
26902700
return;
26912701

26922702
Logger.Info("[App] Auto-connecting local NodeService via EnsureNodeConnectedAsync");

src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs

Lines changed: 84 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -321,20 +321,22 @@ private void ApplyPlan(ConnectionPagePlan plan)
321321
bool isRecovery = plan.Mode == ConnectionPageMode.Recovery;
322322
bool isAdding = plan.Mode == ConnectionPageMode.AddGateway;
323323

324-
// Operator + Node cards only when we actually have an active operator
325-
// connection AND we're not in a focused sub-view (Welcome / Recovery /
326-
// AddGateway). Recovery's help block carries the action; the role
327-
// cards would just compete with it.
324+
// Operator + Node cards are normally tied to an active operator session.
325+
// Local MCP-only mode has no operator session, but still needs the Node
326+
// card so users can see that MCP is serving local tools.
328327
bool hasOperatorSession = _lastSnapshot.OverallState is
329328
OverallConnectionState.Connected
330329
or OverallConnectionState.Ready
331330
or OverallConnectionState.Degraded
332331
or OverallConnectionState.Connecting
333332
or OverallConnectionState.PairingRequired
334333
or OverallConnectionState.Disconnecting;
335-
bool showRoles = hasOperatorSession && !isWelcome && !isAdding && !isRecovery;
334+
var hasStandaloneNodeCard = plan.NodeCard != NodeCardState.Hidden && !hasOperatorSession;
335+
bool showRoles = (hasOperatorSession || hasStandaloneNodeCard) && !isAdding && !isRecovery;
336336
CockpitPanel.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed;
337-
OperatorSection.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed;
337+
OperatorSection.Visibility = showRoles && plan.OperatorCard != OperatorCardState.Hidden
338+
? Visibility.Visible
339+
: Visibility.Collapsed;
338340

339341
// Bottom section: exactly one of these is visible
340342
// • SavedGatewaysCard — Cockpit / Recovery (always present when registry has items)
@@ -814,6 +816,14 @@ or NodeCardState.OnNodeRateLimited
814816
Helpers.FluentIconCatalog.StatusOk,
815817
"SystemFillColorSuccessBrush",
816818
capCount == 1 ? LocalizationHelper.GetString("ConnectionPage_NodeActiveOneCapability") : string.Format(LocalizationHelper.GetString("ConnectionPage_NodeActiveCapabilities"), capCount)),
819+
NodeCardState.OnNodeConnecting => (
820+
Helpers.FluentIconCatalog.Sync,
821+
"SystemFillColorCautionBrush",
822+
LocalizationHelper.GetString("ConnectionPage_NodeStarting")),
823+
NodeCardState.OffMcpOnly => (
824+
Helpers.FluentIconCatalog.Terminal,
825+
"SystemFillColorAttentionBrush",
826+
LocalizationHelper.GetString("ConnectionPage_NodeMcpOnly")),
817827
NodeCardState.OnPermissionsIncomplete => (
818828
Helpers.FluentIconCatalog.StatusWarn,
819829
"SystemFillColorCautionBrush",
@@ -858,47 +868,72 @@ or NodeCardState.OnNodeRateLimited
858868
? ResolveBrush("SystemFillColorCriticalBrush")
859869
: ResolveBrush("TextFillColorPrimaryBrush");
860870

861-
// The gateway's node-list contract owns this boundary. Pending
862-
// declarations are visible for approval context but never counted or
863-
// labeled as approved/effective.
864-
bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off
865-
&& plan.NodeCard != NodeCardState.Hidden;
866-
NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
867-
NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
868-
NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
869-
if (showSurfaces)
870-
{
871-
NodeCapabilityText.Text = BuildNodeSurfaceListString(
872-
"ConnectionPage_NodeEffectiveCapabilities",
873-
plan.NodeEffectiveCapabilities);
874-
NodeCommandText.Text = BuildNodeSurfaceListString(
875-
"ConnectionPage_NodeEffectiveCommands",
876-
plan.NodeEffectiveCommands);
877-
NodePermissionText.Text = BuildNodePermissionListString(
878-
"ConnectionPage_NodeEffectivePermissions",
879-
plan.NodeEffectivePermissions);
880-
}
881-
882-
var showPendingDeclarations = showSurfaces &&
883-
(plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or
884-
GatewayNodeApprovalState.PendingReapproval ||
885-
plan.NodePendingDeclaredCapabilities.Count > 0 ||
886-
plan.NodePendingDeclaredCommands.Count > 0 ||
887-
plan.NodePendingDeclaredPermissions.Count > 0);
888-
NodePendingDeclarationsPanel.Visibility = showPendingDeclarations
889-
? Visibility.Visible
890-
: Visibility.Collapsed;
891-
if (showPendingDeclarations)
871+
if (plan.NodeCard == NodeCardState.OffMcpOnly)
872+
{
873+
NodeCapabilityText.Visibility = Visibility.Visible;
874+
NodeCapabilityText.Text = LocalizationHelper.Format(
875+
"ConnectionPage_NodeMcpOnlyReachable", NodeService.McpServerUrl);
876+
NodeCommandText.Visibility = Visibility.Collapsed;
877+
NodePermissionText.Visibility = Visibility.Collapsed;
878+
NodePendingDeclarationsPanel.Visibility = Visibility.Collapsed;
879+
880+
var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
881+
if (!string.IsNullOrEmpty(mcpError))
882+
{
883+
NodeStatusIcon.Glyph = Helpers.FluentIconCatalog.StatusErr;
884+
NodeStatusIcon.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
885+
NodeStatusText.Text = LocalizationHelper.GetString("ConnectionPage_NodeMcpError");
886+
NodeStatusText.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
887+
NodeCapabilityText.Visibility = Visibility.Collapsed;
888+
NodeBodyText.Text = mcpError;
889+
NodeBodyText.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
890+
NodeBodyText.Visibility = Visibility.Visible;
891+
}
892+
}
893+
else
892894
{
893-
NodePendingCapabilityText.Text = BuildNodeSurfaceListString(
894-
"ConnectionPage_NodePendingDeclaredCapabilities",
895-
plan.NodePendingDeclaredCapabilities);
896-
NodePendingCommandText.Text = BuildNodeSurfaceListString(
897-
"ConnectionPage_NodePendingDeclaredCommands",
898-
plan.NodePendingDeclaredCommands);
899-
NodePendingPermissionText.Text = BuildNodePermissionListString(
900-
"ConnectionPage_NodePendingDeclaredPermissions",
901-
plan.NodePendingDeclaredPermissions);
895+
// Pending declarations are visible for approval context but never
896+
// counted as the active node contract.
897+
bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off
898+
&& plan.NodeCard != NodeCardState.Hidden
899+
&& plan.NodeCard != NodeCardState.OnNodeConnecting;
900+
NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
901+
NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
902+
NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
903+
if (showSurfaces)
904+
{
905+
NodeCapabilityText.Text = BuildNodeSurfaceListString(
906+
"ConnectionPage_NodeEffectiveCapabilities",
907+
plan.NodeEffectiveCapabilities);
908+
NodeCommandText.Text = BuildNodeSurfaceListString(
909+
"ConnectionPage_NodeEffectiveCommands",
910+
plan.NodeEffectiveCommands);
911+
NodePermissionText.Text = BuildNodePermissionListString(
912+
"ConnectionPage_NodeEffectivePermissions",
913+
plan.NodeEffectivePermissions);
914+
}
915+
916+
var showPendingDeclarations = showSurfaces &&
917+
(plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or
918+
GatewayNodeApprovalState.PendingReapproval ||
919+
plan.NodePendingDeclaredCapabilities.Count > 0 ||
920+
plan.NodePendingDeclaredCommands.Count > 0 ||
921+
plan.NodePendingDeclaredPermissions.Count > 0);
922+
NodePendingDeclarationsPanel.Visibility = showPendingDeclarations
923+
? Visibility.Visible
924+
: Visibility.Collapsed;
925+
if (showPendingDeclarations)
926+
{
927+
NodePendingCapabilityText.Text = BuildNodeSurfaceListString(
928+
"ConnectionPage_NodePendingDeclaredCapabilities",
929+
plan.NodePendingDeclaredCapabilities);
930+
NodePendingCommandText.Text = BuildNodeSurfaceListString(
931+
"ConnectionPage_NodePendingDeclaredCommands",
932+
plan.NodePendingDeclaredCommands);
933+
NodePendingPermissionText.Text = BuildNodePermissionListString(
934+
"ConnectionPage_NodePendingDeclaredPermissions",
935+
plan.NodePendingDeclaredPermissions);
936+
}
902937
}
903938

904939
// Sync toggle from current settings (suppress event)
@@ -1102,7 +1137,9 @@ private List<Border> BuildCapabilityChips(IReadOnlyList<string>? capabilities, N
11021137
{
11031138
var chips = new List<Border>();
11041139
if (capabilities == null || capabilities.Count == 0) return chips;
1105-
if (state == NodeCardState.Off || state == NodeCardState.Hidden) return chips;
1140+
if (state == NodeCardState.Off || state == NodeCardState.Hidden
1141+
|| state == NodeCardState.OffMcpOnly || state == NodeCardState.OnNodeConnecting)
1142+
return chips;
11061143

11071144
void Add(string label, bool enabled, bool warn = false, bool error = false)
11081145
{

src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ internal enum NodeCardState
8686
{
8787
Hidden,
8888
Off,
89+
/// <summary>Gateway node is off, local MCP server is enabled.</summary>
90+
OffMcpOnly,
8991
OnHealthy,
92+
/// <summary>Node role is connecting / starting up (not yet ready).</summary>
93+
OnNodeConnecting,
9094
OnPermissionsIncomplete,
9195
OnNodeApprovalRequired,
9296
OnNodeReapprovalRequired,
@@ -220,7 +224,7 @@ private static ConnectionPagePlan BuildDerived(
220224
// ─── Derived layout ───
221225
return snap.OverallState switch
222226
{
223-
OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord),
227+
OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord, settings),
224228

225229
OverallConnectionState.Connecting => BuildCockpitConnecting(snap, activeRecord, displayName),
226230

@@ -249,16 +253,20 @@ private static ConnectionPagePlan BuildDerived(
249253
ActiveGatewayHasSshTunnel = activeRecord?.SshTunnel != null,
250254
},
251255

252-
_ => BuildIdle(savedGatewayCount, activeRecord),
256+
_ => BuildIdle(savedGatewayCount, activeRecord, settings),
253257
};
254258
}
255259

256260
// ───────────────────────────────────────────────────────────────────
257261
// Mode builders
258262
// ───────────────────────────────────────────────────────────────────
259263

260-
private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activeRecord)
264+
private static ConnectionPagePlan BuildIdle(
265+
int savedCount,
266+
GatewayRecord? activeRecord,
267+
SettingsManager? settings)
261268
{
269+
var idleNodeCard = BuildIdleNodeCardState(settings);
262270
if (savedCount == 0)
263271
{
264272
return new ConnectionPagePlan
@@ -268,18 +276,20 @@ private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activ
268276
StripAccent = ConnectionAccent.Neutral,
269277
StripHeadline = "No gateway yet",
270278
StripSub = "Add a gateway to get started.",
279+
NodeCard = idleNodeCard,
271280
};
272281
}
273282

274283
// Saved gateways exist but none active — drop straight into Cockpit
275-
// (Operator/Node panels hide themselves because OperatorCardState=Hidden).
284+
// (role panels hide themselves unless local MCP-only status is visible).
276285
return new ConnectionPagePlan
277286
{
278287
Mode = ConnectionPageMode.Cockpit,
279288
StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.System,
280289
StripAccent = ConnectionAccent.Neutral,
281290
StripHeadline = "Not connected",
282291
StripSub = "Pick a gateway below, or add a new one.",
292+
NodeCard = idleNodeCard,
283293
RelevantGatewayId = activeRecord?.Id,
284294
};
285295
}
@@ -617,7 +627,8 @@ GatewayNodeApprovalState.PendingApproval or
617627
var nodeCardAllowsTrustOverride = plan.NodeCard is
618628
NodeCardState.OnHealthy or
619629
NodeCardState.OnPermissionsIncomplete or
620-
NodeCardState.OnNodePairingRequired ||
630+
NodeCardState.OnNodePairingRequired or
631+
NodeCardState.OnNodeConnecting ||
621632
nodeConnectingAllowsTrustOverride;
622633
// Authoritative node-list trust can override any non-device-pair card.
623634
// Snapshot fallback is narrower: Unknown stays on discovery-only pairing UI.
@@ -685,14 +696,16 @@ NodeCardState.OnPermissionsIncomplete or
685696
private static NodeCardState BuildNodeCardState(GatewayConnectionSnapshot snap, SettingsManager? settings)
686697
{
687698
if (settings == null) return NodeCardState.Hidden;
688-
if (!settings.EnableNodeMode) return NodeCardState.Off;
689699

690-
// Operator must be connected for the node card to be meaningful.
700+
if (!settings.EnableNodeMode)
701+
return settings.EnableMcpServer ? NodeCardState.OffMcpOnly : NodeCardState.Off;
702+
691703
if (snap.OperatorState != RoleConnectionState.Connected)
692704
return NodeCardState.Off;
693705

694706
return snap.NodeState switch
695707
{
708+
RoleConnectionState.Connecting => NodeCardState.OnNodeConnecting,
696709
RoleConnectionState.PairingRequired => NodeCardState.OnNodePairingRequired,
697710
RoleConnectionState.PairingRejected => NodeCardState.OnNodeRejected,
698711
RoleConnectionState.RateLimited => NodeCardState.OnNodeRateLimited,
@@ -702,6 +715,15 @@ _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsInc
702715
};
703716
}
704717

718+
private static NodeCardState BuildIdleNodeCardState(SettingsManager? settings)
719+
{
720+
if (settings == null) return NodeCardState.Hidden;
721+
722+
return !settings.EnableNodeMode && settings.EnableMcpServer
723+
? NodeCardState.OffMcpOnly
724+
: NodeCardState.Hidden;
725+
}
726+
705727
private static string? BuildNodeApproveCommand(GatewayConnectionSnapshot snap)
706728
{
707729
if (snap.NodeState != RoleConnectionState.PairingRequired) return null;

src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@
175175
<TextBlock x:Uid="PermissionsPage_McpHeader" Text="Local MCP Server"
176176
Style="{StaticResource BodyStrongTextBlockStyle}"/>
177177
<TextBlock x:Uid="PermissionsPage_McpDescription"
178-
Text="Expose capabilities over HTTP for CLI tools and local integrations."
178+
Text="Serves capabilities to local MCP clients (CLI tools, integrations) on this PC over HTTP."
179179
Style="{StaticResource CaptionTextBlockStyle}"
180180
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
181181
TextWrapping="Wrap"/>

0 commit comments

Comments
 (0)