865 lines
36 KiB
C#
865 lines
36 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading.Tasks;
|
|
using MCPForUnity.Editor.Clients;
|
|
using MCPForUnity.Editor.Constants;
|
|
using MCPForUnity.Editor.Helpers;
|
|
using MCPForUnity.Editor.Models;
|
|
using MCPForUnity.Editor.Services;
|
|
using MCPForUnity.Editor.Setup;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
|
|
namespace MCPForUnity.Editor.Windows.Components.ClientConfig
|
|
{
|
|
/// <summary>
|
|
/// Controller for the Client Configuration section of the MCP for Unity editor window.
|
|
/// Handles client selection, configuration, status display, and manual configuration details.
|
|
/// </summary>
|
|
public class McpClientConfigSection
|
|
{
|
|
// UI Elements
|
|
private DropdownField clientDropdown;
|
|
private Button configureAllButton;
|
|
private VisualElement clientStatusIndicator;
|
|
private Label clientStatusLabel;
|
|
private Button configureButton;
|
|
private Button installSkillsButton;
|
|
private VisualElement claudeCliPathRow;
|
|
private TextField claudeCliPath;
|
|
private Button browseClaudeButton;
|
|
private VisualElement clientProjectDirRow;
|
|
private TextField clientProjectDirField;
|
|
private Button browseProjectDirButton;
|
|
private Button clearProjectDirButton;
|
|
private Foldout clientDetailsFoldout;
|
|
private Foldout manualConfigFoldout;
|
|
private TextField configPathField;
|
|
private Button copyPathButton;
|
|
private Button openFileButton;
|
|
private TextField configJsonField;
|
|
private Button copyJsonButton;
|
|
private Label installationStepsLabel;
|
|
|
|
// Data
|
|
private readonly List<IMcpClientConfigurator> configurators;
|
|
private readonly Dictionary<IMcpClientConfigurator, DateTime> lastStatusChecks = new();
|
|
private readonly HashSet<IMcpClientConfigurator> statusRefreshInFlight = new();
|
|
private static readonly TimeSpan StatusRefreshInterval = TimeSpan.FromSeconds(45);
|
|
private int selectedClientIndex = 0;
|
|
private bool isSkillSyncInProgress;
|
|
|
|
// Events
|
|
/// <summary>
|
|
/// Fired when the selected client's configured transport is detected/updated.
|
|
/// The parameter contains the client name and its configured transport.
|
|
/// </summary>
|
|
public event Action<string, ConfiguredTransport> OnClientTransportDetected;
|
|
|
|
/// <summary>
|
|
/// Fired when a config mismatch is detected (e.g., version mismatch).
|
|
/// The parameter contains the client name and the mismatch message (null if no mismatch).
|
|
/// </summary>
|
|
public event Action<string, string> OnClientConfigMismatch;
|
|
|
|
public VisualElement Root { get; private set; }
|
|
|
|
public McpClientConfigSection(VisualElement root)
|
|
{
|
|
Root = root;
|
|
configurators = MCPServiceLocator.Client.GetAllClients().ToList();
|
|
CacheUIElements();
|
|
InitializeUI();
|
|
RegisterCallbacks();
|
|
}
|
|
|
|
private void CacheUIElements()
|
|
{
|
|
clientDropdown = Root.Q<DropdownField>("client-dropdown");
|
|
configureAllButton = Root.Q<Button>("configure-all-button");
|
|
clientStatusIndicator = Root.Q<VisualElement>("client-status-indicator");
|
|
clientStatusLabel = Root.Q<Label>("client-status");
|
|
configureButton = Root.Q<Button>("configure-button");
|
|
installSkillsButton = Root.Q<Button>("install-skills-button");
|
|
claudeCliPathRow = Root.Q<VisualElement>("claude-cli-path-row");
|
|
claudeCliPath = Root.Q<TextField>("claude-cli-path");
|
|
browseClaudeButton = Root.Q<Button>("browse-claude-button");
|
|
clientProjectDirRow = Root.Q<VisualElement>("client-project-dir-row");
|
|
clientProjectDirField = Root.Q<TextField>("client-project-dir");
|
|
browseProjectDirButton = Root.Q<Button>("browse-project-dir-button");
|
|
clearProjectDirButton = Root.Q<Button>("clear-project-dir-button");
|
|
clientDetailsFoldout = Root.Q<Foldout>("client-details-foldout");
|
|
manualConfigFoldout = Root.Q<Foldout>("manual-config-foldout");
|
|
configPathField = Root.Q<TextField>("config-path");
|
|
copyPathButton = Root.Q<Button>("copy-path-button");
|
|
openFileButton = Root.Q<Button>("open-file-button");
|
|
configJsonField = Root.Q<TextField>("config-json");
|
|
copyJsonButton = Root.Q<Button>("copy-json-button");
|
|
installationStepsLabel = Root.Q<Label>("installation-steps");
|
|
}
|
|
|
|
private void InitializeUI()
|
|
{
|
|
// Ensure manual config foldout starts collapsed
|
|
if (manualConfigFoldout != null)
|
|
{
|
|
manualConfigFoldout.value = false;
|
|
}
|
|
|
|
// Default the per-client setup foldout to expanded. The Configure / Unregister
|
|
// button for the currently-selected client lives inside it, and the 9.7.0
|
|
// default-collapsed behavior made users believe the Unregister action had been
|
|
// removed (community report: "the Unregister button was removed in 9.7.0,
|
|
// something is stuck on stdio and I can't switch to local"). The state still
|
|
// persists per-user, so anyone who explicitly collapses it keeps that preference.
|
|
if (clientDetailsFoldout != null)
|
|
{
|
|
clientDetailsFoldout.value = EditorPrefs.GetBool(EditorPrefKeys.ClientDetailsFoldoutOpen, true);
|
|
clientDetailsFoldout.RegisterValueChangedCallback(evt =>
|
|
{
|
|
EditorPrefs.SetBool(EditorPrefKeys.ClientDetailsFoldoutOpen, evt.newValue);
|
|
});
|
|
}
|
|
|
|
var clientNames = configurators.Select(c => c.DisplayName).ToList();
|
|
clientDropdown.choices = clientNames;
|
|
if (clientNames.Count > 0)
|
|
{
|
|
// Restore last selected client from EditorPrefs
|
|
string lastClientId = EditorPrefs.GetString(EditorPrefKeys.LastSelectedClientId, string.Empty);
|
|
int restoredIndex = FindConfiguratorIndex(lastClientId);
|
|
if (restoredIndex < 0)
|
|
restoredIndex = 0;
|
|
|
|
clientDropdown.index = restoredIndex;
|
|
selectedClientIndex = restoredIndex;
|
|
}
|
|
|
|
claudeCliPathRow.style.display = DisplayStyle.None;
|
|
clientProjectDirRow.style.display = DisplayStyle.None;
|
|
|
|
// Initialize the configuration display for the first selected client
|
|
UpdateClientStatus();
|
|
UpdateManualConfiguration();
|
|
UpdateClaudeCliPathVisibility();
|
|
UpdateClientProjectDirVisibility();
|
|
UpdateInstallSkillsVisibility();
|
|
}
|
|
|
|
private void RegisterCallbacks()
|
|
{
|
|
clientDropdown.RegisterValueChangedCallback(evt =>
|
|
{
|
|
int selectedIndex = GetIndexForDropdownValue(evt.newValue);
|
|
if (selectedIndex < 0)
|
|
{
|
|
selectedIndex = clientDropdown.index;
|
|
}
|
|
if (selectedIndex < 0 || selectedIndex >= configurators.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
selectedClientIndex = selectedIndex;
|
|
// Persist the selected client so it's restored on next window open
|
|
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
|
|
{
|
|
EditorPrefs.SetString(EditorPrefKeys.LastSelectedClientId, configurators[selectedClientIndex].Id);
|
|
}
|
|
UpdateClientStatus();
|
|
UpdateManualConfiguration();
|
|
UpdateClaudeCliPathVisibility();
|
|
UpdateClientProjectDirVisibility();
|
|
UpdateInstallSkillsVisibility();
|
|
});
|
|
|
|
configureAllButton.clicked += OnConfigureAllClientsClicked;
|
|
configureButton.clicked += OnConfigureClicked;
|
|
installSkillsButton.clicked += OnInstallSkillsClicked;
|
|
browseClaudeButton.clicked += OnBrowseClaudeClicked;
|
|
browseProjectDirButton.clicked += OnBrowseProjectDirClicked;
|
|
clearProjectDirButton.clicked += OnClearProjectDirClicked;
|
|
copyPathButton.clicked += OnCopyPathClicked;
|
|
openFileButton.clicked += OnOpenFileClicked;
|
|
copyJsonButton.clicked += OnCopyJsonClicked;
|
|
}
|
|
|
|
public void UpdateClientStatus()
|
|
{
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
var client = configurators[selectedClientIndex];
|
|
RefreshClientStatus(client);
|
|
}
|
|
|
|
private string GetStatusDisplayString(McpStatus status)
|
|
{
|
|
return status switch
|
|
{
|
|
McpStatus.NotConfigured => "Not Configured",
|
|
McpStatus.Configured => "Configured",
|
|
McpStatus.Running => "Running",
|
|
McpStatus.Connected => "Connected",
|
|
McpStatus.IncorrectPath => "Incorrect Path",
|
|
McpStatus.CommunicationError => "Communication Error",
|
|
McpStatus.NoResponse => "No Response",
|
|
McpStatus.UnsupportedOS => "Unsupported OS",
|
|
McpStatus.MissingConfig => "Missing MCPForUnity Config",
|
|
McpStatus.Error => "Error",
|
|
McpStatus.VersionMismatch => "Version Mismatch",
|
|
_ => "Unknown",
|
|
};
|
|
}
|
|
|
|
public void UpdateManualConfiguration()
|
|
{
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
var client = configurators[selectedClientIndex];
|
|
|
|
string configPath = client.GetConfigPath();
|
|
configPathField.value = configPath;
|
|
|
|
string configJson = client.GetManualSnippet();
|
|
configJsonField.value = configJson;
|
|
|
|
var steps = client.GetInstallationSteps();
|
|
if (steps != null && steps.Count > 0)
|
|
{
|
|
var numbered = steps.Select((s, i) => $"{i + 1}. {s}");
|
|
installationStepsLabel.text = string.Join("\n", numbered);
|
|
}
|
|
else
|
|
{
|
|
installationStepsLabel.text = "Configuration steps not available for this client.";
|
|
}
|
|
}
|
|
|
|
private void UpdateClaudeCliPathVisibility()
|
|
{
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
var client = configurators[selectedClientIndex];
|
|
|
|
if (client is ClaudeCliMcpConfigurator)
|
|
{
|
|
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
|
|
if (string.IsNullOrEmpty(claudePath))
|
|
{
|
|
claudeCliPathRow.style.display = DisplayStyle.Flex;
|
|
claudeCliPath.value = "Not found - click Browse to select";
|
|
}
|
|
else
|
|
{
|
|
claudeCliPathRow.style.display = DisplayStyle.Flex;
|
|
claudeCliPath.value = claudePath;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
claudeCliPathRow.style.display = DisplayStyle.None;
|
|
}
|
|
}
|
|
|
|
private void UpdateClientProjectDirVisibility()
|
|
{
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
var client = configurators[selectedClientIndex];
|
|
|
|
if (client is ClaudeCliMcpConfigurator)
|
|
{
|
|
clientProjectDirRow.style.display = DisplayStyle.Flex;
|
|
string projectDir = ClaudeCliMcpConfigurator.GetClientProjectDir();
|
|
if (ClaudeCliMcpConfigurator.HasClientProjectDirOverride)
|
|
{
|
|
clientProjectDirField.value = projectDir + " (override)";
|
|
}
|
|
else
|
|
{
|
|
clientProjectDirField.value = projectDir;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
clientProjectDirRow.style.display = DisplayStyle.None;
|
|
}
|
|
}
|
|
|
|
private void OnConfigureAllClientsClicked()
|
|
{
|
|
try
|
|
{
|
|
var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients();
|
|
|
|
string headline = summary.SkippedCount > 0
|
|
? $"{summary.SuccessCount + summary.FailureCount} detected client(s) processed. ({summary.SkippedCount} not installed, skipped.)"
|
|
: summary.GetSummaryMessage();
|
|
string message = headline + "\n\n";
|
|
foreach (var msg in summary.Messages)
|
|
{
|
|
message += msg + "\n";
|
|
}
|
|
|
|
EditorUtility.DisplayDialog("Configure Detected Clients", message, "OK");
|
|
|
|
// The bulk path mutated state for every detected client. Clear the per-client
|
|
// status cache so any subsequent dropdown switch (or the currently-selected
|
|
// client refresh below) reads fresh status from disk instead of the pre-bulk
|
|
// value — otherwise every other client in the dropdown looks like it still
|
|
// has Missing/IncorrectPath status until 45 s elapses on the throttle.
|
|
lastStatusChecks.Clear();
|
|
|
|
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
|
|
{
|
|
UpdateClientStatus();
|
|
UpdateManualConfiguration();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EditorUtility.DisplayDialog("Configuration Failed", ex.Message, "OK");
|
|
}
|
|
}
|
|
|
|
private void OnConfigureClicked()
|
|
{
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
var client = configurators[selectedClientIndex];
|
|
|
|
// Handle CLI configurators asynchronously
|
|
if (client is ClaudeCliMcpConfigurator)
|
|
{
|
|
ConfigureClaudeCliAsync(client);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Per-client toggle: Configure→register, Unregister→remove. The Configure path
|
|
// on JsonFile / Codex configurators is always idempotent-write so the bulk
|
|
// "Configure All" can call it unconditionally; the toggle lives here in the UI
|
|
// handler so the manual button still does what its label says.
|
|
if (client.Status == McpStatus.Configured)
|
|
{
|
|
client.Unregister();
|
|
}
|
|
else
|
|
{
|
|
MCPServiceLocator.Client.ConfigureClient(client);
|
|
}
|
|
lastStatusChecks.Remove(client);
|
|
RefreshClientStatus(client, forceImmediate: true);
|
|
UpdateManualConfiguration();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
clientStatusLabel.text = "Error";
|
|
clientStatusLabel.style.color = Color.red;
|
|
McpLog.Error($"Configuration failed: {ex.Message}");
|
|
EditorUtility.DisplayDialog("Configuration Failed", ex.Message, "OK");
|
|
}
|
|
}
|
|
|
|
private void ConfigureClaudeCliAsync(IMcpClientConfigurator client)
|
|
{
|
|
if (statusRefreshInFlight.Contains(client))
|
|
return;
|
|
|
|
statusRefreshInFlight.Add(client);
|
|
bool isCurrentlyConfigured = client.Status == McpStatus.Configured;
|
|
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Configuring...");
|
|
|
|
// Capture ALL main-thread-only values before async task
|
|
string projectDir = ClaudeCliMcpConfigurator.GetClientProjectDir();
|
|
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
|
|
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
|
|
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
|
|
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
|
|
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
|
|
string uvxDevFlags = AssetPathUtility.GetUvxDevFlags();
|
|
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
|
|
|
|
// Compute pathPrepend on main thread
|
|
string pathPrepend = null;
|
|
if (Application.platform == RuntimePlatform.OSXEditor)
|
|
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
|
|
else if (Application.platform == RuntimePlatform.LinuxEditor)
|
|
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
|
|
try
|
|
{
|
|
string claudeDir = Path.GetDirectoryName(claudePath);
|
|
if (!string.IsNullOrEmpty(claudeDir))
|
|
pathPrepend = string.IsNullOrEmpty(pathPrepend) ? claudeDir : $"{claudeDir}:{pathPrepend}";
|
|
}
|
|
catch { }
|
|
|
|
Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
if (client is ClaudeCliMcpConfigurator cliConfigurator)
|
|
{
|
|
var serverTransport = HttpEndpointUtility.GetCurrentServerTransport();
|
|
cliConfigurator.ConfigureWithCapturedValues(
|
|
projectDir, claudePath, pathPrepend,
|
|
useHttpTransport, httpUrl,
|
|
uvxPath, fromArgs, packageName, uvxDevFlags,
|
|
apiKey, serverTransport);
|
|
}
|
|
return (success: true, error: (string)null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (success: false, error: ex.Message);
|
|
}
|
|
}).ContinueWith(t =>
|
|
{
|
|
string errorMessage = null;
|
|
if (t.IsFaulted && t.Exception != null)
|
|
{
|
|
errorMessage = t.Exception.GetBaseException()?.Message ?? "Configuration failed";
|
|
}
|
|
else if (!t.Result.success)
|
|
{
|
|
errorMessage = t.Result.error;
|
|
}
|
|
|
|
EditorApplication.delayCall += () =>
|
|
{
|
|
statusRefreshInFlight.Remove(client);
|
|
lastStatusChecks.Remove(client);
|
|
|
|
if (errorMessage != null)
|
|
{
|
|
if (client is McpClientConfiguratorBase baseConfigurator)
|
|
{
|
|
baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage);
|
|
}
|
|
McpLog.Error($"Configuration failed: {errorMessage}");
|
|
RefreshClientStatus(client, forceImmediate: true);
|
|
}
|
|
else
|
|
{
|
|
// Registration succeeded - trust the status set by RegisterWithCapturedValues
|
|
// and update UI without re-verifying (which could fail due to CLI timing/scope issues)
|
|
lastStatusChecks[client] = DateTime.UtcNow;
|
|
ApplyStatusToUi(client);
|
|
}
|
|
UpdateManualConfiguration();
|
|
};
|
|
});
|
|
}
|
|
|
|
private void UpdateInstallSkillsVisibility()
|
|
{
|
|
if (installSkillsButton == null)
|
|
return;
|
|
|
|
bool visible = selectedClientIndex >= 0
|
|
&& selectedClientIndex < configurators.Count
|
|
&& configurators[selectedClientIndex].SupportsSkills;
|
|
|
|
installSkillsButton.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
|
|
private void OnInstallSkillsClicked()
|
|
{
|
|
if (isSkillSyncInProgress)
|
|
return;
|
|
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
var client = configurators[selectedClientIndex];
|
|
if (!client.SupportsSkills)
|
|
return;
|
|
|
|
string installPath = client.GetSkillInstallPath();
|
|
if (string.IsNullOrEmpty(installPath))
|
|
return;
|
|
|
|
string branch = AssetPathUtility.IsPreReleaseVersion() ? "beta" : "main";
|
|
|
|
isSkillSyncInProgress = true;
|
|
installSkillsButton.SetEnabled(false);
|
|
installSkillsButton.text = "Syncing...";
|
|
|
|
SkillSyncService.SyncAsync(installPath, branch, null, result =>
|
|
{
|
|
isSkillSyncInProgress = false;
|
|
installSkillsButton.SetEnabled(true);
|
|
installSkillsButton.text = "Install Skills";
|
|
|
|
if (result.Success)
|
|
{
|
|
bool noChanges = result.Added == 0 && result.Updated == 0 && result.Deleted == 0;
|
|
string summary = noChanges
|
|
? "Skills are already up to date."
|
|
: $"Added: {result.Added}, Updated: {result.Updated}, Deleted: {result.Deleted}";
|
|
McpLog.Info($"SkillSync complete: {summary} ({installPath})");
|
|
EditorUtility.DisplayDialog("Install Skills",
|
|
$"{summary}\n\nInstalled at: {installPath}", "OK");
|
|
}
|
|
else
|
|
{
|
|
McpLog.Error($"SkillSync failed: {result.Error}");
|
|
EditorUtility.DisplayDialog("Install Skills Failed", result.Error, "OK");
|
|
}
|
|
});
|
|
}
|
|
|
|
private void OnBrowseClaudeClicked()
|
|
{
|
|
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|
|
? "/opt/homebrew/bin"
|
|
: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
|
string picked = EditorUtility.OpenFilePanel("Select Claude CLI", suggested, "");
|
|
if (!string.IsNullOrEmpty(picked))
|
|
{
|
|
try
|
|
{
|
|
MCPServiceLocator.Paths.SetClaudeCliPathOverride(picked);
|
|
UpdateClaudeCliPathVisibility();
|
|
UpdateClientStatus();
|
|
McpLog.Info($"Claude CLI path override set to: {picked}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnBrowseProjectDirClicked()
|
|
{
|
|
string currentDir = ClaudeCliMcpConfigurator.GetClientProjectDir();
|
|
string picked = EditorUtility.OpenFolderPanel("Select Client Project Directory", currentDir, "");
|
|
if (!string.IsNullOrEmpty(picked))
|
|
{
|
|
if (!Directory.Exists(picked))
|
|
{
|
|
EditorUtility.DisplayDialog("Invalid Path", "The selected directory does not exist.", "OK");
|
|
return;
|
|
}
|
|
EditorPrefs.SetString(EditorPrefKeys.ClientProjectDirOverride, picked);
|
|
UpdateClientProjectDirVisibility();
|
|
UpdateClientStatus();
|
|
McpLog.Info($"Client project directory override set to: {picked}");
|
|
}
|
|
}
|
|
|
|
private void OnClearProjectDirClicked()
|
|
{
|
|
EditorPrefs.DeleteKey(EditorPrefKeys.ClientProjectDirOverride);
|
|
UpdateClientProjectDirVisibility();
|
|
UpdateClientStatus();
|
|
McpLog.Info("Client project directory override cleared, using Unity project directory.");
|
|
}
|
|
|
|
private void OnCopyPathClicked()
|
|
{
|
|
EditorGUIUtility.systemCopyBuffer = configPathField.value;
|
|
McpLog.Info("Config path copied to clipboard");
|
|
}
|
|
|
|
private void OnOpenFileClicked()
|
|
{
|
|
string path = configPathField.value;
|
|
try
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
EditorUtility.DisplayDialog("Open File", "The configuration file path does not exist.", "OK");
|
|
return;
|
|
}
|
|
|
|
Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = path,
|
|
UseShellExecute = true
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
McpLog.Error($"Failed to open file: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void OnCopyJsonClicked()
|
|
{
|
|
EditorGUIUtility.systemCopyBuffer = configJsonField.value;
|
|
McpLog.Info("Configuration copied to clipboard");
|
|
}
|
|
|
|
public void RefreshSelectedClient(bool forceImmediate = false)
|
|
{
|
|
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
|
|
{
|
|
var client = configurators[selectedClientIndex];
|
|
// Force immediate for non-Claude CLI, or when explicitly requested
|
|
bool shouldForceImmediate = forceImmediate || client is not ClaudeCliMcpConfigurator;
|
|
RefreshClientStatus(client, shouldForceImmediate);
|
|
UpdateManualConfiguration();
|
|
UpdateClaudeCliPathVisibility();
|
|
}
|
|
}
|
|
|
|
private void RefreshClientStatus(IMcpClientConfigurator client, bool forceImmediate = false)
|
|
{
|
|
if (client is ClaudeCliMcpConfigurator)
|
|
{
|
|
RefreshClaudeCliStatus(client, forceImmediate);
|
|
return;
|
|
}
|
|
|
|
if (forceImmediate || ShouldRefreshClient(client))
|
|
{
|
|
MCPServiceLocator.Client.CheckClientStatus(client);
|
|
lastStatusChecks[client] = DateTime.UtcNow;
|
|
}
|
|
|
|
ApplyStatusToUi(client);
|
|
}
|
|
|
|
private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImmediate)
|
|
{
|
|
bool hasStatus = lastStatusChecks.ContainsKey(client);
|
|
bool needsRefresh = !hasStatus || ShouldRefreshClient(client);
|
|
|
|
if (!hasStatus)
|
|
{
|
|
ApplyStatusToUi(client, showChecking: true);
|
|
}
|
|
else
|
|
{
|
|
ApplyStatusToUi(client);
|
|
}
|
|
|
|
if ((forceImmediate || needsRefresh) && !statusRefreshInFlight.Contains(client))
|
|
{
|
|
statusRefreshInFlight.Add(client);
|
|
ApplyStatusToUi(client, showChecking: true);
|
|
|
|
// Capture main-thread-only values before async task
|
|
string projectDir = ClaudeCliMcpConfigurator.GetClientProjectDir();
|
|
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
|
|
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
|
|
RuntimePlatform platform = Application.platform;
|
|
bool isRemoteScope = HttpEndpointUtility.IsRemoteScope();
|
|
// Get expected package source based on installed package version and overrides.
|
|
string expectedPackageSource = GetExpectedPackageSourceForCurrentPackage();
|
|
bool hasProjectDirOverride = ClaudeCliMcpConfigurator.HasClientProjectDirOverride;
|
|
|
|
Task.Run(() =>
|
|
{
|
|
// Defensive: RefreshClientStatus routes Claude CLI clients here, but avoid hard-cast
|
|
// so accidental future call sites can't crash the UI.
|
|
if (client is ClaudeCliMcpConfigurator claudeConfigurator)
|
|
{
|
|
// Use thread-safe version with captured main-thread values
|
|
claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite: false, hasProjectDirOverride: hasProjectDirOverride);
|
|
}
|
|
}).ContinueWith(t =>
|
|
{
|
|
bool faulted = false;
|
|
string errorMessage = null;
|
|
if (t.IsFaulted && t.Exception != null)
|
|
{
|
|
var baseException = t.Exception.GetBaseException();
|
|
errorMessage = baseException?.Message ?? "Status check failed";
|
|
McpLog.Error($"Failed to refresh Claude CLI status: {errorMessage}");
|
|
faulted = true;
|
|
}
|
|
|
|
EditorApplication.delayCall += () =>
|
|
{
|
|
statusRefreshInFlight.Remove(client);
|
|
lastStatusChecks[client] = DateTime.UtcNow;
|
|
if (faulted)
|
|
{
|
|
if (client is McpClientConfiguratorBase baseConfigurator)
|
|
{
|
|
baseConfigurator.Client.SetStatus(McpStatus.Error, errorMessage ?? "Status check failed");
|
|
}
|
|
}
|
|
ApplyStatusToUi(client);
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
private bool ShouldRefreshClient(IMcpClientConfigurator client)
|
|
{
|
|
if (!lastStatusChecks.TryGetValue(client, out var last))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return (DateTime.UtcNow - last) > StatusRefreshInterval;
|
|
}
|
|
|
|
private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = false, string customMessage = null)
|
|
{
|
|
if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count)
|
|
return;
|
|
|
|
if (!ReferenceEquals(configurators[selectedClientIndex], client))
|
|
return;
|
|
|
|
clientStatusIndicator.RemoveFromClassList("configured");
|
|
clientStatusIndicator.RemoveFromClassList("not-configured");
|
|
clientStatusIndicator.RemoveFromClassList("warning");
|
|
|
|
if (showChecking)
|
|
{
|
|
clientStatusLabel.text = customMessage ?? "Checking...";
|
|
clientStatusLabel.style.color = StyleKeyword.Null;
|
|
clientStatusIndicator.AddToClassList("warning");
|
|
configureButton.text = client.GetConfigureActionLabel();
|
|
return;
|
|
}
|
|
|
|
// Check for transport mismatch (3-way: Stdio, Http, HttpRemote).
|
|
// Skip when a project dir override is active — the registered transport
|
|
// in the overridden project may legitimately differ from the local server.
|
|
bool hasTransportMismatch = false;
|
|
if (client.ConfiguredTransport != ConfiguredTransport.Unknown
|
|
&& !ClaudeCliMcpConfigurator.HasClientProjectDirOverride)
|
|
{
|
|
ConfiguredTransport serverTransport = HttpEndpointUtility.GetCurrentServerTransport();
|
|
hasTransportMismatch = client.ConfiguredTransport != serverTransport;
|
|
}
|
|
|
|
// If configured but with transport mismatch, show warning state
|
|
if (hasTransportMismatch && (client.Status == McpStatus.Configured || client.Status == McpStatus.Running || client.Status == McpStatus.Connected))
|
|
{
|
|
clientStatusLabel.text = "Transport Mismatch";
|
|
clientStatusIndicator.AddToClassList("warning");
|
|
}
|
|
else
|
|
{
|
|
clientStatusLabel.text = GetStatusDisplayString(client.Status);
|
|
|
|
switch (client.Status)
|
|
{
|
|
case McpStatus.Configured:
|
|
case McpStatus.Running:
|
|
case McpStatus.Connected:
|
|
clientStatusIndicator.AddToClassList("configured");
|
|
break;
|
|
case McpStatus.IncorrectPath:
|
|
case McpStatus.CommunicationError:
|
|
case McpStatus.NoResponse:
|
|
case McpStatus.Error:
|
|
case McpStatus.VersionMismatch:
|
|
clientStatusIndicator.AddToClassList("warning");
|
|
break;
|
|
default:
|
|
clientStatusIndicator.AddToClassList("not-configured");
|
|
break;
|
|
}
|
|
}
|
|
|
|
clientStatusLabel.style.color = StyleKeyword.Null;
|
|
configureButton.text = client.GetConfigureActionLabel();
|
|
|
|
// Notify listeners about the client's configured transport
|
|
OnClientTransportDetected?.Invoke(client.DisplayName, client.ConfiguredTransport);
|
|
|
|
// Notify listeners about version mismatch if applicable
|
|
if (client.Status == McpStatus.VersionMismatch && client is McpClientConfiguratorBase baseConfigurator)
|
|
{
|
|
// Get the mismatch reason from the configStatus field
|
|
string mismatchReason = baseConfigurator.Client.configStatus;
|
|
OnClientConfigMismatch?.Invoke(client.DisplayName, mismatchReason);
|
|
}
|
|
else
|
|
{
|
|
// Clear any previous mismatch warning
|
|
OnClientConfigMismatch?.Invoke(client.DisplayName, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the expected package source for validation based on installed package version.
|
|
/// Uses the same logic as registration to ensure validation matches what was registered.
|
|
/// MUST be called from the main thread due to EditorPrefs access.
|
|
/// </summary>
|
|
private static string GetExpectedPackageSourceForCurrentPackage()
|
|
{
|
|
return AssetPathUtility.GetMcpServerPackageSource();
|
|
}
|
|
|
|
private int FindConfiguratorIndex(string persistedClientValue)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(persistedClientValue))
|
|
return -1;
|
|
|
|
// Primary match: stored stable ID.
|
|
for (int i = 0; i < configurators.Count; i++)
|
|
{
|
|
if (string.Equals(configurators[i].Id, persistedClientValue, StringComparison.OrdinalIgnoreCase))
|
|
return i;
|
|
}
|
|
|
|
// Compatibility match for older persisted values (e.g., display names with spaces).
|
|
string normalized = NormalizeClientToken(persistedClientValue);
|
|
if (string.IsNullOrEmpty(normalized))
|
|
return -1;
|
|
|
|
for (int i = 0; i < configurators.Count; i++)
|
|
{
|
|
if (NormalizeClientToken(configurators[i].Id) == normalized ||
|
|
NormalizeClientToken(configurators[i].DisplayName) == normalized)
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
private int GetIndexForDropdownValue(string dropdownValue)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(dropdownValue))
|
|
return -1;
|
|
|
|
int directIndex = clientDropdown.choices?.IndexOf(dropdownValue) ?? -1;
|
|
if (directIndex >= 0 && directIndex < configurators.Count)
|
|
return directIndex;
|
|
|
|
string normalized = NormalizeClientToken(dropdownValue);
|
|
if (string.IsNullOrEmpty(normalized))
|
|
return -1;
|
|
|
|
for (int i = 0; i < configurators.Count; i++)
|
|
{
|
|
if (NormalizeClientToken(configurators[i].DisplayName) == normalized)
|
|
return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
private static string NormalizeClientToken(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return string.Empty;
|
|
|
|
return new string(value.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
|
|
}
|
|
}
|
|
}
|