diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 026099fa7..4c2f57162 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -67,6 +67,8 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands
internal VoiceService? VoiceService => _nodeService?.VoiceService ?? _standaloneVoiceService;
/// The full device ID of the local node service (if running).
internal string? NodeFullDeviceId => _nodeService?.FullDeviceId;
+ /// Live node service instance used by settings surfaces for MCP status.
+ internal NodeService? ActiveNodeService => _nodeService;
///
/// Session key that the chat surface should select on its next mount.
@@ -644,7 +646,7 @@ _dispatcherQueue is null
credentialResolver, clientFactory, _gatewayRegistry, appLogger,
identityStore: new DeviceIdentityFileStore(appLogger),
nodeConnector: nodeConnector,
- isNodeEnabled: ShouldInitializeNodeService,
+ isNodeEnabled: IsGatewayNodeEnabled,
diagnostics: diagnostics,
tunnelManager: _sshTunnelService);
_connectionManager.OperatorClientChanged += OnOperatorClientChanged;
@@ -1541,7 +1543,7 @@ record = SyncGatewayBrowserProxyForward(record);
if (credential == null)
{
var nodeCredential = ResolveStartupNodeCredential(record, resolver, identityDir);
- if (nodeCredential != null && ShouldInitializeNodeService())
+ if (nodeCredential != null && IsGatewayNodeEnabled())
{
Logger.Info(
$"Connecting node-only gateway during {context}: {record.Url} ({nodeCredential.Source})");
@@ -1562,6 +1564,8 @@ record = SyncGatewayBrowserProxyForward(record);
ObserveBackgroundFault(
_connectionManager.ConnectAsync(record.Id),
$"[App] Startup gateway connect failed during {context}");
+ if (!IsGatewayNodeEnabled())
+ TryStartLocalMcpOnlyNode();
return true;
}
@@ -1891,6 +1895,12 @@ private bool ShouldInitializeNodeService()
return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true;
}
+ /// True when this PC should connect as a gateway node.
+ private bool IsGatewayNodeEnabled()
+ {
+ return _settings?.EnableNodeMode == true;
+ }
+
///
/// Ensures a WSL keepalive process is running for the local gateway distro
/// so the WSL2 VM stays up even after the tray exits.
@@ -2672,8 +2682,8 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s
if (status == ConnectionStatus.Connected)
{
_ = RunHealthCheckAsync();
- // For local gateways, the NodeConnector is suppressed because NodeService
- // owns the identity. Connect the NodeService directly after operator connects.
+ // Gateway-node mode connects the NodeService after operator auth; MCP-only
+ // mode keeps serving local tools and must not escalate into node pairing.
_ = TryConnectLocalNodeServiceAsync();
}
}
@@ -2686,7 +2696,7 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s
///
private async Task TryConnectLocalNodeServiceAsync()
{
- if (_connectionManager == null)
+ if (_connectionManager == null || !IsGatewayNodeEnabled())
return;
Logger.Info("[App] Auto-connecting local NodeService via EnsureNodeConnectedAsync");
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
index 065b5e044..b26936f96 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
@@ -321,10 +321,9 @@ private void ApplyPlan(ConnectionPagePlan plan)
bool isRecovery = plan.Mode == ConnectionPageMode.Recovery;
bool isAdding = plan.Mode == ConnectionPageMode.AddGateway;
- // Operator + Node cards only when we actually have an active operator
- // connection AND we're not in a focused sub-view (Welcome / Recovery /
- // AddGateway). Recovery's help block carries the action; the role
- // cards would just compete with it.
+ // Operator + Node cards are normally tied to an active operator session.
+ // Local MCP-only mode has no operator session, but still needs the Node
+ // card so users can see that MCP is serving local tools.
bool hasOperatorSession = _lastSnapshot.OverallState is
OverallConnectionState.Connected
or OverallConnectionState.Ready
@@ -332,9 +331,12 @@ or OverallConnectionState.Degraded
or OverallConnectionState.Connecting
or OverallConnectionState.PairingRequired
or OverallConnectionState.Disconnecting;
- bool showRoles = hasOperatorSession && !isWelcome && !isAdding && !isRecovery;
+ var hasStandaloneNodeCard = plan.NodeCard != NodeCardState.Hidden && !hasOperatorSession;
+ bool showRoles = (hasOperatorSession || hasStandaloneNodeCard) && !isAdding && !isRecovery;
CockpitPanel.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed;
- OperatorSection.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed;
+ OperatorSection.Visibility = showRoles && plan.OperatorCard != OperatorCardState.Hidden
+ ? Visibility.Visible
+ : Visibility.Collapsed;
// Bottom section: exactly one of these is visible
// • SavedGatewaysCard — Cockpit / Recovery (always present when registry has items)
@@ -814,6 +816,14 @@ or NodeCardState.OnNodeRateLimited
Helpers.FluentIconCatalog.StatusOk,
"SystemFillColorSuccessBrush",
capCount == 1 ? LocalizationHelper.GetString("ConnectionPage_NodeActiveOneCapability") : string.Format(LocalizationHelper.GetString("ConnectionPage_NodeActiveCapabilities"), capCount)),
+ NodeCardState.OnNodeConnecting => (
+ Helpers.FluentIconCatalog.Sync,
+ "SystemFillColorCautionBrush",
+ LocalizationHelper.GetString("ConnectionPage_NodeStarting")),
+ NodeCardState.OffMcpOnly => (
+ Helpers.FluentIconCatalog.Terminal,
+ "SystemFillColorAttentionBrush",
+ LocalizationHelper.GetString("ConnectionPage_NodeMcpOnly")),
NodeCardState.OnPermissionsIncomplete => (
Helpers.FluentIconCatalog.StatusWarn,
"SystemFillColorCautionBrush",
@@ -858,47 +868,72 @@ or NodeCardState.OnNodeRateLimited
? ResolveBrush("SystemFillColorCriticalBrush")
: ResolveBrush("TextFillColorPrimaryBrush");
- // The gateway's node-list contract owns this boundary. Pending
- // declarations are visible for approval context but never counted or
- // labeled as approved/effective.
- bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off
- && plan.NodeCard != NodeCardState.Hidden;
- NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
- NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
- NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
- if (showSurfaces)
- {
- NodeCapabilityText.Text = BuildNodeSurfaceListString(
- "ConnectionPage_NodeEffectiveCapabilities",
- plan.NodeEffectiveCapabilities);
- NodeCommandText.Text = BuildNodeSurfaceListString(
- "ConnectionPage_NodeEffectiveCommands",
- plan.NodeEffectiveCommands);
- NodePermissionText.Text = BuildNodePermissionListString(
- "ConnectionPage_NodeEffectivePermissions",
- plan.NodeEffectivePermissions);
- }
-
- var showPendingDeclarations = showSurfaces &&
- (plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or
- GatewayNodeApprovalState.PendingReapproval ||
- plan.NodePendingDeclaredCapabilities.Count > 0 ||
- plan.NodePendingDeclaredCommands.Count > 0 ||
- plan.NodePendingDeclaredPermissions.Count > 0);
- NodePendingDeclarationsPanel.Visibility = showPendingDeclarations
- ? Visibility.Visible
- : Visibility.Collapsed;
- if (showPendingDeclarations)
+ if (plan.NodeCard == NodeCardState.OffMcpOnly)
+ {
+ NodeCapabilityText.Visibility = Visibility.Visible;
+ NodeCapabilityText.Text = LocalizationHelper.Format(
+ "ConnectionPage_NodeMcpOnlyReachable", NodeService.McpServerUrl);
+ NodeCommandText.Visibility = Visibility.Collapsed;
+ NodePermissionText.Visibility = Visibility.Collapsed;
+ NodePendingDeclarationsPanel.Visibility = Visibility.Collapsed;
+
+ var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
+ if (!string.IsNullOrEmpty(mcpError))
+ {
+ NodeStatusIcon.Glyph = Helpers.FluentIconCatalog.StatusErr;
+ NodeStatusIcon.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
+ NodeStatusText.Text = LocalizationHelper.GetString("ConnectionPage_NodeMcpError");
+ NodeStatusText.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
+ NodeCapabilityText.Visibility = Visibility.Collapsed;
+ NodeBodyText.Text = mcpError;
+ NodeBodyText.Foreground = ResolveBrush("SystemFillColorCriticalBrush");
+ NodeBodyText.Visibility = Visibility.Visible;
+ }
+ }
+ else
{
- NodePendingCapabilityText.Text = BuildNodeSurfaceListString(
- "ConnectionPage_NodePendingDeclaredCapabilities",
- plan.NodePendingDeclaredCapabilities);
- NodePendingCommandText.Text = BuildNodeSurfaceListString(
- "ConnectionPage_NodePendingDeclaredCommands",
- plan.NodePendingDeclaredCommands);
- NodePendingPermissionText.Text = BuildNodePermissionListString(
- "ConnectionPage_NodePendingDeclaredPermissions",
- plan.NodePendingDeclaredPermissions);
+ // Pending declarations are visible for approval context but never
+ // counted as the active node contract.
+ bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off
+ && plan.NodeCard != NodeCardState.Hidden
+ && plan.NodeCard != NodeCardState.OnNodeConnecting;
+ NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
+ NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
+ NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed;
+ if (showSurfaces)
+ {
+ NodeCapabilityText.Text = BuildNodeSurfaceListString(
+ "ConnectionPage_NodeEffectiveCapabilities",
+ plan.NodeEffectiveCapabilities);
+ NodeCommandText.Text = BuildNodeSurfaceListString(
+ "ConnectionPage_NodeEffectiveCommands",
+ plan.NodeEffectiveCommands);
+ NodePermissionText.Text = BuildNodePermissionListString(
+ "ConnectionPage_NodeEffectivePermissions",
+ plan.NodeEffectivePermissions);
+ }
+
+ var showPendingDeclarations = showSurfaces &&
+ (plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or
+ GatewayNodeApprovalState.PendingReapproval ||
+ plan.NodePendingDeclaredCapabilities.Count > 0 ||
+ plan.NodePendingDeclaredCommands.Count > 0 ||
+ plan.NodePendingDeclaredPermissions.Count > 0);
+ NodePendingDeclarationsPanel.Visibility = showPendingDeclarations
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ if (showPendingDeclarations)
+ {
+ NodePendingCapabilityText.Text = BuildNodeSurfaceListString(
+ "ConnectionPage_NodePendingDeclaredCapabilities",
+ plan.NodePendingDeclaredCapabilities);
+ NodePendingCommandText.Text = BuildNodeSurfaceListString(
+ "ConnectionPage_NodePendingDeclaredCommands",
+ plan.NodePendingDeclaredCommands);
+ NodePendingPermissionText.Text = BuildNodePermissionListString(
+ "ConnectionPage_NodePendingDeclaredPermissions",
+ plan.NodePendingDeclaredPermissions);
+ }
}
// Sync toggle from current settings (suppress event)
@@ -1102,7 +1137,9 @@ private List BuildCapabilityChips(IReadOnlyList? capabilities, N
{
var chips = new List();
if (capabilities == null || capabilities.Count == 0) return chips;
- if (state == NodeCardState.Off || state == NodeCardState.Hidden) return chips;
+ if (state == NodeCardState.Off || state == NodeCardState.Hidden
+ || state == NodeCardState.OffMcpOnly || state == NodeCardState.OnNodeConnecting)
+ return chips;
void Add(string label, bool enabled, bool warn = false, bool error = false)
{
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
index c32e75d01..e08310a59 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
@@ -86,7 +86,11 @@ internal enum NodeCardState
{
Hidden,
Off,
+ /// Gateway node is off, local MCP server is enabled.
+ OffMcpOnly,
OnHealthy,
+ /// Node role is connecting / starting up (not yet ready).
+ OnNodeConnecting,
OnPermissionsIncomplete,
OnNodeApprovalRequired,
OnNodeReapprovalRequired,
@@ -220,7 +224,7 @@ private static ConnectionPagePlan BuildDerived(
// ─── Derived layout ───
return snap.OverallState switch
{
- OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord),
+ OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord, settings),
OverallConnectionState.Connecting => BuildCockpitConnecting(snap, activeRecord, displayName),
@@ -249,7 +253,7 @@ private static ConnectionPagePlan BuildDerived(
ActiveGatewayHasSshTunnel = activeRecord?.SshTunnel != null,
},
- _ => BuildIdle(savedGatewayCount, activeRecord),
+ _ => BuildIdle(savedGatewayCount, activeRecord, settings),
};
}
@@ -257,8 +261,12 @@ private static ConnectionPagePlan BuildDerived(
// Mode builders
// ───────────────────────────────────────────────────────────────────
- private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activeRecord)
+ private static ConnectionPagePlan BuildIdle(
+ int savedCount,
+ GatewayRecord? activeRecord,
+ SettingsManager? settings)
{
+ var idleNodeCard = BuildIdleNodeCardState(settings);
if (savedCount == 0)
{
return new ConnectionPagePlan
@@ -268,11 +276,12 @@ private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activ
StripAccent = ConnectionAccent.Neutral,
StripHeadline = "No gateway yet",
StripSub = "Add a gateway to get started.",
+ NodeCard = idleNodeCard,
};
}
// Saved gateways exist but none active — drop straight into Cockpit
- // (Operator/Node panels hide themselves because OperatorCardState=Hidden).
+ // (role panels hide themselves unless local MCP-only status is visible).
return new ConnectionPagePlan
{
Mode = ConnectionPageMode.Cockpit,
@@ -280,6 +289,7 @@ private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activ
StripAccent = ConnectionAccent.Neutral,
StripHeadline = "Not connected",
StripSub = "Pick a gateway below, or add a new one.",
+ NodeCard = idleNodeCard,
RelevantGatewayId = activeRecord?.Id,
};
}
@@ -617,7 +627,8 @@ GatewayNodeApprovalState.PendingApproval or
var nodeCardAllowsTrustOverride = plan.NodeCard is
NodeCardState.OnHealthy or
NodeCardState.OnPermissionsIncomplete or
- NodeCardState.OnNodePairingRequired ||
+ NodeCardState.OnNodePairingRequired or
+ NodeCardState.OnNodeConnecting ||
nodeConnectingAllowsTrustOverride;
// Authoritative node-list trust can override any non-device-pair card.
// Snapshot fallback is narrower: Unknown stays on discovery-only pairing UI.
@@ -685,14 +696,16 @@ NodeCardState.OnPermissionsIncomplete or
private static NodeCardState BuildNodeCardState(GatewayConnectionSnapshot snap, SettingsManager? settings)
{
if (settings == null) return NodeCardState.Hidden;
- if (!settings.EnableNodeMode) return NodeCardState.Off;
- // Operator must be connected for the node card to be meaningful.
+ if (!settings.EnableNodeMode)
+ return settings.EnableMcpServer ? NodeCardState.OffMcpOnly : NodeCardState.Off;
+
if (snap.OperatorState != RoleConnectionState.Connected)
return NodeCardState.Off;
return snap.NodeState switch
{
+ RoleConnectionState.Connecting => NodeCardState.OnNodeConnecting,
RoleConnectionState.PairingRequired => NodeCardState.OnNodePairingRequired,
RoleConnectionState.PairingRejected => NodeCardState.OnNodeRejected,
RoleConnectionState.RateLimited => NodeCardState.OnNodeRateLimited,
@@ -702,6 +715,15 @@ _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsInc
};
}
+ private static NodeCardState BuildIdleNodeCardState(SettingsManager? settings)
+ {
+ if (settings == null) return NodeCardState.Hidden;
+
+ return !settings.EnableNodeMode && settings.EnableMcpServer
+ ? NodeCardState.OffMcpOnly
+ : NodeCardState.Hidden;
+ }
+
private static string? BuildNodeApproveCommand(GatewayConnectionSnapshot snap)
{
if (snap.NodeState != RoleConnectionState.PairingRequired) return null;
diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
index 320f47b98..aa1c46309 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
@@ -175,7 +175,7 @@
diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
index 9ab3e5fb0..1309f89b8 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
+using OpenClaw.Connection;
using OpenClaw.Shared;
using OpenClaw.Shared.Capabilities;
using OpenClawTray.Helpers;
@@ -24,6 +25,7 @@ public sealed partial class PermissionsPage : Page
private bool _suppressTtsProviderChange;
private readonly List _featureToggles = new();
private List _policyRules = new();
+ private const int BrowserProxyToggleIndex = 1;
// Sentinel rendered into the API key PasswordBox so the user can see
// that a key is already saved without us ever surfacing the plaintext.
@@ -56,12 +58,29 @@ private void OnLoaded(object sender, RoutedEventArgs e)
{
if (CurrentApp.Settings != null)
CurrentApp.Settings.Saved += OnSettingsSaved;
+
+ var mgr = CurrentApp.ConnectionManager;
+ if (mgr != null)
+ mgr.StateChanged += OnConnectionStateChanged;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
if (CurrentApp.Settings != null)
CurrentApp.Settings.Saved -= OnSettingsSaved;
+
+ var mgr = CurrentApp.ConnectionManager;
+ if (mgr != null)
+ mgr.StateChanged -= OnConnectionStateChanged;
+ }
+
+ private void OnConnectionStateChanged(object? sender, GatewayConnectionSnapshot snapshot)
+ {
+ DispatcherQueue?.TryEnqueue(() =>
+ {
+ if (!IsLoaded) return;
+ UpdateNodeStatus();
+ });
}
private bool _suppressNodeModeToggle;
@@ -119,18 +138,18 @@ private void ReloadFeatureToggleStates()
}
}
- ///
- /// Disables and dims the sub-toggles when Node Mode is off so users see they have
- /// no effect until Node Mode is back on. ItemsRepeater isn't a Control (no IsEnabled),
- /// so we apply per-toggle plus an Opacity on the repeater.
- ///
+ /// Enables capability toggles whenever either node transport can serve them.
private void ApplyFeaturesEnabledState()
{
- var nodeEnabled = CurrentApp.Settings?.EnableNodeMode ?? false;
- CapabilityRepeater.Opacity = nodeEnabled ? 1.0 : 0.4;
- foreach (var toggle in _featureToggles)
- toggle.IsEnabled = nodeEnabled;
- FeaturesSectionDescription.Text = LocalizationHelper.GetString(nodeEnabled
+ var s = CurrentApp.Settings;
+ var canServe = (s?.EnableNodeMode ?? false) || (s?.EnableMcpServer ?? false);
+ CapabilityRepeater.Opacity = canServe ? 1.0 : 0.4;
+ for (int i = 0; i < _featureToggles.Count; i++)
+ {
+ var isBrowserProxyToggle = i == BrowserProxyToggleIndex;
+ _featureToggles[i].IsEnabled = canServe && (!isBrowserProxyToggle || s?.EnableNodeMode == true);
+ }
+ FeaturesSectionDescription.Text = LocalizationHelper.GetString(canServe
? "PermissionsPage_FeaturesDescription_Enabled"
: "PermissionsPage_FeaturesDescription_Disabled");
}
@@ -472,16 +491,52 @@ private void OnTtsElevenLabsCommitted(object sender, RoutedEventArgs e)
private void UpdateNodeStatus()
{
- var nodeEnabled = CurrentApp.Settings?.EnableNodeMode ?? false;
- var isConnected = (CurrentApp.AppState?.Status ?? ConnectionStatus.Disconnected) == ConnectionStatus.Connected;
+ var settings = CurrentApp.Settings;
+ var nodeEnabled = settings?.EnableNodeMode ?? false;
+ var mcpEnabled = settings?.EnableMcpServer ?? false;
if (!nodeEnabled)
{
- NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray);
- NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Disabled");
- NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_DisabledDetails");
+ if (mcpEnabled && settings != null)
+ {
+ var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
+ if (!string.IsNullOrEmpty(mcpError))
+ {
+ NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed);
+ NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError");
+ NodeDetailsText.Text = mcpError;
+ }
+ else
+ {
+ NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.DodgerBlue);
+ NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpOnly");
+ NodeDetailsText.Text = LocalizationHelper.Format(
+ "PermissionsPage_NodeStatus_McpOnlyDetailsFormat",
+ NodeCapabilityGating.CountMcpServedCapabilities(settings),
+ NodeService.McpServerUrl);
+ }
+ }
+ else
+ {
+ NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray);
+ NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Disabled");
+ NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_DisabledDetails");
+ }
+ return;
}
- else if (isConnected)
+
+ var snap = CurrentApp.ConnectionManager?.CurrentSnapshot;
+ var nodeState = snap?.NodeState ?? RoleConnectionState.Idle;
+ var operatorConnected = snap?.OperatorState == RoleConnectionState.Connected;
+ var mcpStartupError = CurrentApp.ActiveNodeService?.McpStartupError;
+
+ if (mcpEnabled && !string.IsNullOrEmpty(mcpStartupError))
+ {
+ NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed);
+ NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError");
+ NodeDetailsText.Text = mcpStartupError;
+ }
+ else if (nodeState == RoleConnectionState.Connected && operatorConnected)
{
NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.LimeGreen);
NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Active");
@@ -496,11 +551,22 @@ private void UpdateNodeStatus()
caps.Count, string.Join(", ", caps))
: LocalizationHelper.GetString("PermissionsPage_NodeStatus_NoCapabilities");
}
+ else if (nodeState == RoleConnectionState.Connecting)
+ {
+ NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Goldenrod);
+ NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Starting");
+ NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails");
+ }
else
{
NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Orange);
NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnected");
- NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails");
+ NodeDetailsText.Text = mcpEnabled && settings != null && string.IsNullOrEmpty(mcpStartupError)
+ ? LocalizationHelper.Format(
+ "PermissionsPage_NodeStatus_McpOnlyDetailsFormat",
+ NodeCapabilityGating.CountMcpServedCapabilities(settings),
+ NodeService.McpServerUrl)
+ : LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails");
}
}
@@ -519,6 +585,14 @@ private void UpdateMcpStatus()
if (settings.EnableMcpServer)
{
+ var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
+ if (!string.IsNullOrEmpty(mcpError))
+ {
+ McpStatusText.Text =
+ $"{LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError")}: {mcpError}";
+ return;
+ }
+
var tokenPath = NodeService.McpTokenPath;
var tokenExists = File.Exists(tokenPath);
McpStatusText.Text = LocalizationHelper.GetString(tokenExists
@@ -535,6 +609,8 @@ private void OnMcpToggled(object sender, RoutedEventArgs e)
CurrentApp.Settings.Save();
((IAppCommands)CurrentApp).NotifySettingsSaved();
UpdateMcpStatus();
+ UpdateNodeStatus();
+ ApplyFeaturesEnabledState();
}
private void OnCopyMcpToken(object sender, RoutedEventArgs e)
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs
index a26bd026e..6a2c2911b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs
@@ -61,4 +61,17 @@ public static bool ShouldRegisterBrowserProxy(SettingsManager? s, string? shared
}
public static bool ShouldRegisterSystemRun(SettingsManager? s) => s?.NodeSystemRunEnabled != false;
+
+ /// Counts node capability categories served by local MCP without a gateway node client.
+ public static int CountMcpServedCapabilities(SettingsManager? s)
+ {
+ int n = 2; // system + device are always registered
+ if (ShouldRegisterCanvas(s)) n++;
+ if (ShouldRegisterScreen(s)) n++;
+ if (ShouldRegisterCamera(s)) n++;
+ if (ShouldRegisterLocation(s)) n++;
+ if (ShouldRegisterTts(s)) n++;
+ if (ShouldRegisterStt(s)) n++;
+ return n;
+ }
}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 8bcb4e73f..02652edf6 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -1684,7 +1684,7 @@ On your gateway host (Mac/Linux), run:
Local MCP Server
- Expose capabilities via HTTP for CLI tools and local integrations.
+ Serves capabilities to local MCP clients (CLI tools, integrations) on this PC over HTTP.
Endpoint:
@@ -3547,6 +3547,18 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
Providing {0} capabilities: {1}
+
+ Local MCP only
+
+
+ Serving {0} capabilities to local MCP clients at {1}
+
+
+ Node starting…
+
+
+ Local MCP server failed to start
+
No capabilities enabled.
@@ -5329,6 +5341,18 @@ Make sure the gateway is running.
Node mode disabled
+
+ Node starting…
+
+
+ Serving capabilities locally (MCP only)
+
+
+ Reachable by local MCP clients at {0}
+
+
+ Local MCP server failed to start
+
No capabilities enabled. Pick what to share in Permissions.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 7a415c547..96ea77906 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -1636,7 +1636,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Serveur MCP local
- Exposez les fonctionnalités via HTTP pour les outils CLI et les intégrations locales.
+ Fournit des fonctionnalités aux clients MCP locaux (outils CLI, intégrations) sur ce PC via HTTP.
Point de terminaison :
@@ -3499,6 +3499,18 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
Providing {0} capabilities: {1}
+
+ MCP local uniquement
+
+
+ Fourniture de {0} capacités aux clients MCP locaux à {1}
+
+
+ Node en cours de démarrage…
+
+
+ Échec du démarrage du serveur MCP local
+
No capabilities enabled.
@@ -5281,6 +5293,18 @@ Assurez-vous que la passerelle est en cours d'exécution.
Mode nœud désactivé
+
+ Node en cours de démarrage…
+
+
+ Capacités fournies localement (MCP uniquement)
+
+
+ Accessible par les clients MCP locaux à {0}
+
+
+ Échec du démarrage du serveur MCP local
+
Aucune capacité activée. Choisissez ce que vous souhaitez partager dans les Autorisations.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index ce5b67238..01dde0c68 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -1637,7 +1637,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Lokale MCP-server
- Stel mogelijkheden beschikbaar via HTTP voor CLI-hulpprogramma’s en lokale integraties.
+ Levert mogelijkheden aan lokale MCP-clients (CLI-hulpprogramma's, integraties) op deze pc via HTTP.
Eindpunt:
@@ -3500,6 +3500,18 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
Providing {0} capabilities: {1}
+
+ Alleen lokale MCP
+
+
+ Levert {0} mogelijkheden aan lokale MCP-clients op {1}
+
+
+ Node wordt gestart…
+
+
+ Lokale MCP-server kon niet worden gestart
+
No capabilities enabled.
@@ -5282,6 +5294,18 @@ Controleer of de gateway actief is.
Nodemodus uitgeschakeld
+
+ Node wordt gestart…
+
+
+ Mogelijkheden lokaal beschikbaar (alleen MCP)
+
+
+ Bereikbaar voor lokale MCP-clients op {0}
+
+
+ Lokale MCP-server kon niet worden gestart
+
Geen mogelijkheden ingeschakeld. Kies wat u wilt delen bij Machtigingen.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 42a6cd3e7..38e5ecbad 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -1636,7 +1636,7 @@
本地 MCP 服务器
- 通过 HTTP 向 CLI 工具和本地集成公开功能。
+ 通过 HTTP 向此电脑上的本地 MCP 客户端(CLI 工具、集成)提供功能。
终结点:
@@ -3499,6 +3499,18 @@
Providing {0} capabilities: {1}
+
+ 仅本地 MCP
+
+
+ 正在通过 {1} 向本地 MCP 客户端提供 {0} 项功能
+
+
+ Node 正在启动…
+
+
+ 本地 MCP 服务器启动失败
+
No capabilities enabled.
@@ -5282,6 +5294,18 @@
节点模式已禁用
+
+ Node 正在启动…
+
+
+ 正在本地提供功能(仅 MCP)
+
+
+ 本地 MCP 客户端可通过 {0} 访问
+
+
+ 本地 MCP 服务器启动失败
+
未启用任何能力。请在权限中选择要共享的内容。
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 971af07ec..08258bfa4 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -1636,7 +1636,7 @@
本機 MCP 伺服器
- 透過 HTTP 向 CLI 工具和本機整合公開功能。
+ 透過 HTTP 向此電腦上的本機 MCP 用戶端(CLI 工具、整合)提供功能。
端點:
@@ -3499,6 +3499,18 @@
Providing {0} capabilities: {1}
+
+ 僅本機 MCP
+
+
+ 正在透過 {1} 向本機 MCP 用戶端提供 {0} 項功能
+
+
+ Node 正在啟動…
+
+
+ 本機 MCP 伺服器啟動失敗
+
No capabilities enabled.
@@ -5282,6 +5294,18 @@
節點模式已停用
+
+ Node 正在啟動…
+
+
+ 正在本機提供功能(僅 MCP)
+
+
+ 本機 MCP 用戶端可透過 {0} 存取
+
+
+ 本機 MCP 伺服器啟動失敗
+
未啟用任何功能。請在權限中選擇要共用的內容。
diff --git a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs
index 9b3423f8e..1a573cfca 100644
--- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs
+++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs
@@ -724,6 +724,14 @@ private async Task ApproveNewPendingDeviceRequestsUntilReadyAsync(
if (credentials.HasOperatorToken && credentials.HasNodeToken && !credentials.HasBootstrapToken)
return;
+ if (credentials.HasOperatorToken && !credentials.HasNodeToken && !credentials.HasBootstrapToken)
+ {
+ using var reconnectNodeDoc = await tray.Client.CallToolExpectSuccessAsync("app.connection.reconnectNode");
+ Assert.True(reconnectNodeDoc.RootElement.GetProperty("reconnected").GetBoolean());
+ await Task.Delay(500);
+ continue;
+ }
+
using var approvals = await ReadPendingApprovalsFromConnectionPageAsync();
lastDevicesOutput = approvals.RootElement.GetRawText();
var approvedAny = false;
diff --git a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
index 5a7c337af..14fc49871 100644
--- a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs
@@ -409,7 +409,9 @@ await WaitForConditionAsync(
Task.Delay(TimeSpan.FromSeconds(3)));
Assert.Same(client.ThirdConnected, reconnect);
- Assert.True(server.AcceptedCount >= 3);
+ await WaitForConditionAsync(
+ () => server.AcceptedCount >= 3,
+ TimeSpan.FromSeconds(2));
client.Dispose();
}
diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
index 1294b1555..46793a2e7 100644
--- a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
+++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
@@ -166,6 +166,55 @@ public void DefaultOnCapabilities_OnlyDisabledWhenExplicitlySetToFalse()
Assert.False(NodeCapabilityGating.ShouldRegisterSystemRun(s));
}
+ // ── CountMcpServedCapabilities ────────────────────────────────────────────
+
+ [Fact]
+ public void CountMcpServed_Defaults_AreSixCapabilities()
+ {
+ var s = NewSettings();
+ Assert.Equal(6, NodeCapabilityGating.CountMcpServedCapabilities(s));
+ }
+
+ [Fact]
+ public void CountMcpServed_NullSettings_AreSix()
+ {
+ Assert.Equal(6, NodeCapabilityGating.CountMcpServedCapabilities(null));
+ }
+
+ [Fact]
+ public void CountMcpServed_ExcludesBrowserProxy()
+ {
+ var s = NewSettings();
+ var before = NodeCapabilityGating.CountMcpServedCapabilities(s);
+ s.NodeBrowserProxyEnabled = false;
+ Assert.Equal(before, NodeCapabilityGating.CountMcpServedCapabilities(s));
+ }
+
+ [Fact]
+ public void CountMcpServed_SystemAndDeviceAlwaysCounted_EvenWhenSystemRunDisabled()
+ {
+ var s = NewSettings();
+ s.NodeCanvasEnabled = false;
+ s.NodeScreenEnabled = false;
+ s.NodeCameraEnabled = false;
+ s.NodeLocationEnabled = false;
+ s.NodeBrowserProxyEnabled = false;
+ s.NodeSystemRunEnabled = false;
+ s.NodeTtsEnabled = false;
+ s.NodeSttEnabled = false;
+ Assert.Equal(2, NodeCapabilityGating.CountMcpServedCapabilities(s));
+ }
+
+ [Fact]
+ public void CountMcpServed_OptInCapabilities_IncrementCount()
+ {
+ var s = NewSettings();
+ var baseline = NodeCapabilityGating.CountMcpServedCapabilities(s);
+ s.NodeTtsEnabled = true;
+ s.NodeSttEnabled = true;
+ Assert.Equal(baseline + 2, NodeCapabilityGating.CountMcpServedCapabilities(s));
+ }
+
// ── GetLocalNodeCapabilities ──────────────────────────────────────────────
[Fact]
diff --git a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs
new file mode 100644
index 000000000..1abe6014c
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs
@@ -0,0 +1,233 @@
+using System.Linq;
+using OpenClaw.Connection;
+using OpenClawTray.Pages;
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests;
+
+///
+/// Pins source-level UI contracts because OpenClaw.Tray.Tests cannot reference
+/// the WinUI assembly directly.
+///
+public sealed class NodeModeUiStateTests
+{
+ [Fact]
+ public void NodeCardState_DeclaresMcpOnlyAndConnecting()
+ {
+ var plan = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs");
+
+ Assert.Contains("OffMcpOnly", plan);
+ Assert.Contains("OnNodeConnecting", plan);
+ }
+
+ [Fact]
+ public void BuildNodeCardState_MapsMcpOnlyWhenNodeModeOffButMcpEnabled()
+ {
+ var plan = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs");
+
+ Assert.Contains(
+ "settings.EnableMcpServer ? NodeCardState.OffMcpOnly : NodeCardState.Off",
+ plan);
+ }
+
+ [Theory]
+ [InlineData(0, (int)ConnectionPageMode.Welcome)]
+ [InlineData(1, (int)ConnectionPageMode.Cockpit)]
+ public void IdlePlan_SurfacesMcpOnlyNodeCardWithoutGatewaySession(
+ int savedGatewayCount,
+ int expectedMode)
+ {
+ var settingsDirectory = Path.Combine(
+ Path.GetTempPath(),
+ "openclaw-node-mode-ui-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var settings = new SettingsManager(settingsDirectory)
+ {
+ EnableMcpServer = true,
+ EnableNodeMode = false
+ };
+
+ var plan = ConnectionPagePlan.Build(
+ GatewayConnectionSnapshot.Idle,
+ activeRecord: null,
+ self: null,
+ settings: settings,
+ savedGatewayCount: savedGatewayCount);
+
+ Assert.Equal((ConnectionPageMode)expectedMode, plan.Mode);
+ Assert.Equal(NodeCardState.OffMcpOnly, plan.NodeCard);
+ Assert.Equal(OperatorCardState.Hidden, plan.OperatorCard);
+ }
+ finally
+ {
+ if (Directory.Exists(settingsDirectory))
+ Directory.Delete(settingsDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void BuildNodeCardState_MapsConnectingToStartingState()
+ {
+ var plan = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs");
+
+ Assert.Contains(
+ "RoleConnectionState.Connecting => NodeCardState.OnNodeConnecting",
+ plan);
+ }
+
+ [Fact]
+ public void ConnectionPage_PresentsMcpOnlyAndStartingStates()
+ {
+ var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs");
+
+ Assert.Contains("NodeCardState.OffMcpOnly", page);
+ Assert.Contains("NodeCardState.OnNodeConnecting", page);
+ Assert.Contains("ConnectionPage_NodeMcpOnly", page);
+ Assert.Contains("ConnectionPage_NodeMcpOnlyReachable", page);
+ Assert.Contains("ConnectionPage_NodeStarting", page);
+ Assert.Contains("NodeService.McpServerUrl", page);
+ Assert.Contains("ConnectionPage_NodeMcpError", page);
+ Assert.Contains("ActiveNodeService", page);
+ Assert.Contains("var hasStandaloneNodeCard = plan.NodeCard != NodeCardState.Hidden && !hasOperatorSession;", page);
+ Assert.Contains("showRoles = (hasOperatorSession || hasStandaloneNodeCard)", page);
+ }
+
+ [Fact]
+ public void App_GatewayNodeConnection_GatedOnNodeModeOnly_NotMcp()
+ {
+ var app = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs");
+ var connectMethod = ExtractMethodBody(app, "bool TryConnectGatewayIfCredentialAvailable");
+
+ Assert.Contains("isNodeEnabled: IsGatewayNodeEnabled", app);
+
+ var gate = ExtractMethodBody(app, "bool IsGatewayNodeEnabled");
+ Assert.Contains("EnableNodeMode == true", gate);
+ Assert.DoesNotContain("EnableMcpServer", gate);
+
+ Assert.Contains("nodeCredential != null && IsGatewayNodeEnabled()", app);
+ Assert.Contains("TryStartLocalMcpOnlyNode()", connectMethod);
+
+ var localNodeConnect = ExtractMethodBody(app, "Task TryConnectLocalNodeServiceAsync");
+ Assert.Contains("!IsGatewayNodeEnabled()", localNodeConnect);
+ Assert.Contains("EnsureNodeConnectedAsync()", localNodeConnect);
+ }
+
+ [Fact]
+ public void PermissionsPage_PresentsMcpOnlyNodeStatus()
+ {
+ var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+
+ Assert.Contains("EnableMcpServer", page);
+ Assert.Contains("PermissionsPage_NodeStatus_McpOnly", page);
+ Assert.Contains("PermissionsPage_NodeStatus_McpOnlyDetailsFormat", page);
+ Assert.Contains("NodeService.McpServerUrl", page);
+ Assert.Contains("CountMcpServedCapabilities", page);
+ Assert.Contains("PermissionsPage_NodeStatus_McpError", page);
+ Assert.Contains("ActiveNodeService", page);
+ Assert.Contains("mcpEnabled && !string.IsNullOrEmpty(mcpStartupError)", page);
+ Assert.Contains("McpStatusText.Text =", page);
+ }
+
+ [Fact]
+ public void PermissionsPage_DrivesNodeStatusFromRoleState_AndSubscribesToChanges()
+ {
+ var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+
+ Assert.Contains("RoleConnectionState", page);
+ Assert.Contains("CurrentSnapshot", page);
+ Assert.Contains("PermissionsPage_NodeStatus_Starting", page);
+ Assert.Contains("StateChanged", page);
+ Assert.Contains("OnConnectionStateChanged", page);
+ }
+
+ [Fact]
+ public void PermissionsPage_CapabilityToggles_StayActionableInMcpOnly()
+ {
+ var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+
+ Assert.Contains(
+ "var canServe = (s?.EnableNodeMode ?? false) || (s?.EnableMcpServer ?? false);",
+ page);
+ Assert.Contains("BrowserProxyToggleIndex", page);
+ Assert.Contains("!isBrowserProxyToggle || s?.EnableNodeMode == true", page);
+ }
+
+ [Fact]
+ public void PermissionsPage_McpToggleRefreshesNodeStatus()
+ {
+ var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+
+ var toggle = ExtractMethodBody(page, "OnMcpToggled");
+ Assert.Contains("UpdateNodeStatus()", toggle);
+ }
+
+ [Fact]
+ public void NewNodeStateStrings_ExistInEnUsResources()
+ {
+ var resw = ReadSource(
+ "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw");
+
+ foreach (var key in new[]
+ {
+ "ConnectionPage_NodeStarting",
+ "ConnectionPage_NodeMcpOnly",
+ "ConnectionPage_NodeMcpOnlyReachable",
+ "ConnectionPage_NodeMcpError",
+ "PermissionsPage_NodeStatus_McpOnly",
+ "PermissionsPage_NodeStatus_McpOnlyDetailsFormat",
+ "PermissionsPage_NodeStatus_Starting",
+ "PermissionsPage_NodeStatus_McpError",
+ })
+ {
+ Assert.Contains($"name=\"{key}\"", resw);
+ }
+ }
+
+ private static string ExtractMethodBody(string source, string methodName)
+ {
+ var sigIndex = source.IndexOf(methodName + "(", System.StringComparison.Ordinal);
+ if (sigIndex < 0) return string.Empty;
+ var bodyStart = source.IndexOf('{', sigIndex);
+ if (bodyStart < 0) return string.Empty;
+ int depth = 0;
+ for (int i = bodyStart; i < source.Length; i++)
+ {
+ if (source[i] == '{') depth++;
+ else if (source[i] == '}')
+ {
+ depth--;
+ if (depth == 0) return source.Substring(bodyStart, i - bodyStart + 1);
+ }
+ }
+ return source.Substring(bodyStart);
+ }
+
+ private static string ReadSource(params string[] relativePathParts)
+ {
+ var root = GetRepositoryRoot();
+ return File.ReadAllText(Path.Combine(new[] { root }.Concat(relativePathParts).ToArray()));
+ }
+
+ private static string GetRepositoryRoot()
+ {
+ var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT");
+ if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env))
+ return env;
+
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) &&
+ Directory.Exists(Path.Combine(directory.FullName, "src")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+
+ throw new InvalidOperationException(
+ "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path.");
+ }
+}