Display resources (#658)

* Add resource discovery service and UI for managing MCP resources

* Consolidate duplicate IsBuiltIn logic into StringCaseUtility.IsBuiltInMcpType

* Add resource enable/disable enforcement and improve error response handling

- Block execution of disabled resources in TransportCommandDispatcher with clear error message
- Add parse_resource_response() utility to handle error responses without Pydantic validation failures
- Replace inline response parsing with parse_resource_response() across all resource handlers
- Export parse_resource_response from models/__init__.py for consistent usage

* Block execution of disabled built-in tools in TransportCommandDispatcher with clear error message

Add tool enable/disable enforcement before command execution. Check tool metadata and enabled state, returning error response if tool is disabled. Prevents execution of disabled tools with user-friendly error message.

* Fire warning in the rare chance there are duplicate names

* Handle rare case a resource name is null

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
main
Marcus Sanatan 2026-01-30 20:31:35 -04:00 committed by GitHub
parent 664a43b76c
commit 61284cc172
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 722 additions and 35 deletions

View File

@ -43,6 +43,8 @@ namespace MCPForUnity.Editor.Constants
internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled";
internal const string ToolEnabledPrefix = "MCPForUnity.ToolEnabled.";
internal const string ToolFoldoutStatePrefix = "MCPForUnity.ToolFoldout.";
internal const string ResourceEnabledPrefix = "MCPForUnity.ResourceEnabled.";
internal const string ResourceFoldoutStatePrefix = "MCPForUnity.ResourceFoldout.";
internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel";
internal const string SetupCompleted = "MCPForUnity.SetupCompleted";

View File

@ -1,3 +1,4 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
@ -10,6 +11,28 @@ namespace MCPForUnity.Editor.Helpers
/// </summary>
public static class StringCaseUtility
{
/// <summary>
/// Checks whether a type belongs to the built-in MCP for Unity package.
/// Returns true when the type's namespace starts with
/// <paramref name="builtInNamespacePrefix"/> or its assembly is MCPForUnity.Editor.
/// </summary>
public static bool IsBuiltInMcpType(Type type, string assemblyName, string builtInNamespacePrefix)
{
if (type != null && !string.IsNullOrEmpty(type.Namespace)
&& type.Namespace.StartsWith(builtInNamespacePrefix, StringComparison.Ordinal))
{
return true;
}
if (!string.IsNullOrEmpty(assemblyName)
&& assemblyName.Equals("MCPForUnity.Editor", StringComparison.Ordinal))
{
return true;
}
return false;
}
/// <summary>
/// Converts a camelCase string to snake_case.
/// Example: "searchMethod" -> "search_method", "param1Value" -> "param1_value"

View File

@ -15,6 +15,11 @@ namespace MCPForUnity.Editor.Resources
/// </summary>
public string ResourceName { get; }
/// <summary>
/// Human-readable description of what this resource provides.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Create an MCP resource attribute with auto-generated resource name.
/// The resource name will be derived from the class name (PascalCase → snake_case).

View File

@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Metadata for a discovered resource
/// </summary>
public class ResourceMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public string ClassName { get; set; }
public string Namespace { get; set; }
public string AssemblyName { get; set; }
public bool IsBuiltIn { get; set; }
}
/// <summary>
/// Service for discovering MCP resources via reflection
/// </summary>
public interface IResourceDiscoveryService
{
/// <summary>
/// Discovers all resources marked with [McpForUnityResource]
/// </summary>
List<ResourceMetadata> DiscoverAllResources();
/// <summary>
/// Gets metadata for a specific resource
/// </summary>
ResourceMetadata GetResourceMetadata(string resourceName);
/// <summary>
/// Returns only the resources currently enabled
/// </summary>
List<ResourceMetadata> GetEnabledResources();
/// <summary>
/// Checks whether a resource is currently enabled
/// </summary>
bool IsResourceEnabled(string resourceName);
/// <summary>
/// Updates the enabled state for a resource
/// </summary>
void SetResourceEnabled(string resourceName, bool enabled);
/// <summary>
/// Invalidates the resource discovery cache
/// </summary>
void InvalidateCache();
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7afb4739669224c74b4b4d706e6bbb49
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -17,6 +17,7 @@ namespace MCPForUnity.Editor.Services
private static IPackageUpdateService _packageUpdateService;
private static IPlatformService _platformService;
private static IToolDiscoveryService _toolDiscoveryService;
private static IResourceDiscoveryService _resourceDiscoveryService;
private static IServerManagementService _serverManagementService;
private static TransportManager _transportManager;
private static IPackageDeploymentService _packageDeploymentService;
@ -28,6 +29,7 @@ namespace MCPForUnity.Editor.Services
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
public static IPlatformService Platform => _platformService ??= new PlatformService();
public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService();
public static IResourceDiscoveryService ResourceDiscovery => _resourceDiscoveryService ??= new ResourceDiscoveryService();
public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService();
public static TransportManager TransportManager => _transportManager ??= new TransportManager();
public static IPackageDeploymentService Deployment => _packageDeploymentService ??= new PackageDeploymentService();
@ -53,6 +55,8 @@ namespace MCPForUnity.Editor.Services
_platformService = ps;
else if (implementation is IToolDiscoveryService td)
_toolDiscoveryService = td;
else if (implementation is IResourceDiscoveryService rd)
_resourceDiscoveryService = rd;
else if (implementation is IServerManagementService sm)
_serverManagementService = sm;
else if (implementation is IPackageDeploymentService pd)
@ -73,6 +77,7 @@ namespace MCPForUnity.Editor.Services
(_packageUpdateService as IDisposable)?.Dispose();
(_platformService as IDisposable)?.Dispose();
(_toolDiscoveryService as IDisposable)?.Dispose();
(_resourceDiscoveryService as IDisposable)?.Dispose();
(_serverManagementService as IDisposable)?.Dispose();
(_transportManager as IDisposable)?.Dispose();
(_packageDeploymentService as IDisposable)?.Dispose();
@ -84,6 +89,7 @@ namespace MCPForUnity.Editor.Services
_packageUpdateService = null;
_platformService = null;
_toolDiscoveryService = null;
_resourceDiscoveryService = null;
_serverManagementService = null;
_transportManager = null;
_packageDeploymentService = null;

View File

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
public class ResourceDiscoveryService : IResourceDiscoveryService
{
private Dictionary<string, ResourceMetadata> _cachedResources;
public List<ResourceMetadata> DiscoverAllResources()
{
if (_cachedResources != null)
{
return _cachedResources.Values.ToList();
}
_cachedResources = new Dictionary<string, ResourceMetadata>();
var resourceTypes = TypeCache.GetTypesWithAttribute<McpForUnityResourceAttribute>();
foreach (var type in resourceTypes)
{
McpForUnityResourceAttribute resourceAttr;
try
{
resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>();
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read [McpForUnityResource] for {type.FullName}: {ex.Message}");
continue;
}
if (resourceAttr == null)
{
continue;
}
var metadata = ExtractResourceMetadata(type, resourceAttr);
if (metadata != null)
{
if (_cachedResources.ContainsKey(metadata.Name))
{
McpLog.Warn($"Duplicate resource name '{metadata.Name}' from {type.FullName}; overwriting previous registration.");
}
_cachedResources[metadata.Name] = metadata;
EnsurePreferenceInitialized(metadata);
}
}
McpLog.Info($"Discovered {_cachedResources.Count} MCP resources via reflection", false);
return _cachedResources.Values.ToList();
}
public ResourceMetadata GetResourceMetadata(string resourceName)
{
if (string.IsNullOrEmpty(resourceName))
{
return null;
}
if (_cachedResources == null)
{
DiscoverAllResources();
}
return _cachedResources.TryGetValue(resourceName, out var metadata) ? metadata : null;
}
public List<ResourceMetadata> GetEnabledResources()
{
return DiscoverAllResources()
.Where(r => IsResourceEnabled(r.Name))
.ToList();
}
public bool IsResourceEnabled(string resourceName)
{
if (string.IsNullOrEmpty(resourceName))
{
return false;
}
string key = GetResourcePreferenceKey(resourceName);
if (EditorPrefs.HasKey(key))
{
return EditorPrefs.GetBool(key, true);
}
// Default: all resources enabled
return true;
}
public void SetResourceEnabled(string resourceName, bool enabled)
{
if (string.IsNullOrEmpty(resourceName))
{
return;
}
string key = GetResourcePreferenceKey(resourceName);
EditorPrefs.SetBool(key, enabled);
}
public void InvalidateCache()
{
_cachedResources = null;
}
private ResourceMetadata ExtractResourceMetadata(Type type, McpForUnityResourceAttribute resourceAttr)
{
try
{
string resourceName = resourceAttr.ResourceName;
if (string.IsNullOrEmpty(resourceName))
{
resourceName = StringCaseUtility.ToSnakeCase(type.Name);
}
string description = resourceAttr.Description ?? $"Resource: {resourceName}";
var metadata = new ResourceMetadata
{
Name = resourceName,
Description = description,
ClassName = type.Name,
Namespace = type.Namespace ?? "",
AssemblyName = type.Assembly.GetName().Name
};
metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType(
type, metadata.AssemblyName, "MCPForUnity.Editor.Resources");
return metadata;
}
catch (Exception ex)
{
McpLog.Error($"Failed to extract metadata for resource {type.Name}: {ex.Message}");
return null;
}
}
private void EnsurePreferenceInitialized(ResourceMetadata metadata)
{
if (metadata == null || string.IsNullOrEmpty(metadata.Name))
{
return;
}
string key = GetResourcePreferenceKey(metadata.Name);
if (!EditorPrefs.HasKey(key))
{
EditorPrefs.SetBool(key, true);
}
}
private static string GetResourcePreferenceKey(string resourceName)
{
return EditorPrefKeys.ResourceEnabledPrefix + resourceName;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66ce49d2cc47a4bd3aa85ac9f099b757
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -45,6 +45,10 @@ namespace MCPForUnity.Editor.Services
var metadata = ExtractToolMetadata(type, toolAttr);
if (metadata != null)
{
if (_cachedTools.ContainsKey(metadata.Name))
{
McpLog.Warn($"Duplicate tool name '{metadata.Name}' from {type.FullName}; overwriting previous registration.");
}
_cachedTools[metadata.Name] = metadata;
EnsurePreferenceInitialized(metadata);
}
@ -131,7 +135,8 @@ namespace MCPForUnity.Editor.Services
PollAction = string.IsNullOrEmpty(toolAttr.PollAction) ? "status" : toolAttr.PollAction
};
metadata.IsBuiltIn = DetermineIsBuiltIn(type, metadata);
metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType(
type, metadata.AssemblyName, "MCPForUnity.Editor.Tools");
return metadata;
@ -239,24 +244,5 @@ namespace MCPForUnity.Editor.Services
return EditorPrefKeys.ToolEnabledPrefix + toolName;
}
private bool DetermineIsBuiltIn(Type type, ToolMetadata metadata)
{
if (metadata == null)
{
return false;
}
if (type != null && !string.IsNullOrEmpty(type.Namespace) && type.Namespace.StartsWith("MCPForUnity.Editor.Tools", StringComparison.Ordinal))
{
return true;
}
if (!string.IsNullOrEmpty(metadata.AssemblyName) && metadata.AssemblyName.Equals("MCPForUnity.Editor", StringComparison.Ordinal))
{
return true;
}
return false;
}
}
}

View File

@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -337,6 +338,27 @@ namespace MCPForUnity.Editor.Services.Transport
}
var parameters = command.@params ?? new JObject();
// Block execution of disabled resources
var resourceMeta = MCPServiceLocator.ResourceDiscovery.GetResourceMetadata(command.type);
if (resourceMeta != null && !MCPServiceLocator.ResourceDiscovery.IsResourceEnabled(command.type))
{
pending.TrySetResult(SerializeError(
$"Resource '{command.type}' is disabled in the Unity Editor."));
RemovePending(id, pending);
return;
}
// Block execution of disabled tools
var toolMeta = MCPServiceLocator.ToolDiscovery.GetToolMetadata(command.type);
if (toolMeta != null && !MCPServiceLocator.ToolDiscovery.IsToolEnabled(command.type))
{
pending.TrySetResult(SerializeError(
$"Tool '{command.type}' is disabled in the Unity Editor."));
RemovePending(id, pending);
return;
}
var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource);
if (result == null)

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 582ec97120b80401cb943b45d15425f9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using UnityEditor;
using UnityEngine.UIElements;
namespace MCPForUnity.Editor.Windows.Components.Resources
{
/// <summary>
/// Controller for the Resources section inside the MCP For Unity editor window.
/// Provides discovery, filtering, and per-resource enablement toggles.
/// </summary>
public class McpResourcesSection
{
private readonly Dictionary<string, Toggle> resourceToggleMap = new();
private Label summaryLabel;
private Label noteLabel;
private Button enableAllButton;
private Button disableAllButton;
private Button rescanButton;
private VisualElement categoryContainer;
private List<ResourceMetadata> allResources = new();
public VisualElement Root { get; }
public McpResourcesSection(VisualElement root)
{
Root = root;
CacheUIElements();
RegisterCallbacks();
}
private void CacheUIElements()
{
summaryLabel = Root.Q<Label>("resources-summary");
noteLabel = Root.Q<Label>("resources-note");
enableAllButton = Root.Q<Button>("enable-all-resources-button");
disableAllButton = Root.Q<Button>("disable-all-resources-button");
rescanButton = Root.Q<Button>("rescan-resources-button");
categoryContainer = Root.Q<VisualElement>("resource-category-container");
}
private void RegisterCallbacks()
{
if (enableAllButton != null)
{
enableAllButton.AddToClassList("tool-action-button");
enableAllButton.style.marginRight = 4;
enableAllButton.clicked += () => SetAllResourcesState(true);
}
if (disableAllButton != null)
{
disableAllButton.AddToClassList("tool-action-button");
disableAllButton.style.marginRight = 4;
disableAllButton.clicked += () => SetAllResourcesState(false);
}
if (rescanButton != null)
{
rescanButton.AddToClassList("tool-action-button");
rescanButton.clicked += () =>
{
McpLog.Info("Rescanning MCP resources from the editor window.");
MCPServiceLocator.ResourceDiscovery.InvalidateCache();
Refresh();
};
}
}
/// <summary>
/// Rebuilds the resource list and synchronises toggle states.
/// </summary>
public void Refresh()
{
resourceToggleMap.Clear();
categoryContainer?.Clear();
var service = MCPServiceLocator.ResourceDiscovery;
allResources = service.DiscoverAllResources()
.OrderBy(r => r.IsBuiltIn ? 0 : 1)
.ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
bool hasResources = allResources.Count > 0;
enableAllButton?.SetEnabled(hasResources);
disableAllButton?.SetEnabled(hasResources);
if (noteLabel != null)
{
noteLabel.style.display = hasResources ? DisplayStyle.Flex : DisplayStyle.None;
}
if (!hasResources)
{
AddInfoLabel("No MCP resources found. Add classes decorated with [McpForUnityResource] to expose resources.");
UpdateSummary();
return;
}
BuildCategory("Built-in Resources", "built-in", allResources.Where(r => r.IsBuiltIn));
var customResources = allResources.Where(r => !r.IsBuiltIn).ToList();
if (customResources.Count > 0)
{
BuildCategory("Custom Resources", "custom", customResources);
}
else
{
AddInfoLabel("No custom resources detected in loaded assemblies.");
}
UpdateSummary();
}
private void BuildCategory(string title, string prefsSuffix, IEnumerable<ResourceMetadata> resources)
{
var resourceList = resources.ToList();
if (resourceList.Count == 0)
{
return;
}
var foldout = new Foldout
{
text = $"{title} ({resourceList.Count})",
value = EditorPrefs.GetBool(EditorPrefKeys.ResourceFoldoutStatePrefix + prefsSuffix, true)
};
foldout.RegisterValueChangedCallback(evt =>
{
EditorPrefs.SetBool(EditorPrefKeys.ResourceFoldoutStatePrefix + prefsSuffix, evt.newValue);
});
foreach (var resource in resourceList)
{
foldout.Add(CreateResourceRow(resource));
}
categoryContainer?.Add(foldout);
}
private VisualElement CreateResourceRow(ResourceMetadata resource)
{
var row = new VisualElement();
row.AddToClassList("tool-item");
var header = new VisualElement();
header.AddToClassList("tool-item-header");
var toggle = new Toggle(resource.Name)
{
value = MCPServiceLocator.ResourceDiscovery.IsResourceEnabled(resource.Name)
};
toggle.AddToClassList("tool-item-toggle");
toggle.tooltip = string.IsNullOrWhiteSpace(resource.Description) ? resource.Name : resource.Description;
toggle.RegisterValueChangedCallback(evt =>
{
HandleToggleChange(resource, evt.newValue);
});
resourceToggleMap[resource.Name] = toggle;
header.Add(toggle);
var tagsContainer = new VisualElement();
tagsContainer.AddToClassList("tool-tags");
tagsContainer.Add(CreateTag(resource.IsBuiltIn ? "Built-in" : "Custom"));
header.Add(tagsContainer);
row.Add(header);
if (!string.IsNullOrWhiteSpace(resource.Description) && !resource.Description.StartsWith("Resource:", StringComparison.Ordinal))
{
var description = new Label(resource.Description);
description.AddToClassList("tool-item-description");
row.Add(description);
}
return row;
}
private void HandleToggleChange(ResourceMetadata resource, bool enabled, bool updateSummary = true)
{
MCPServiceLocator.ResourceDiscovery.SetResourceEnabled(resource.Name, enabled);
if (updateSummary)
{
UpdateSummary();
}
}
private void SetAllResourcesState(bool enabled)
{
foreach (var resource in allResources)
{
if (!resourceToggleMap.TryGetValue(resource.Name, out var toggle))
{
MCPServiceLocator.ResourceDiscovery.SetResourceEnabled(resource.Name, enabled);
continue;
}
if (toggle.value == enabled)
{
continue;
}
toggle.SetValueWithoutNotify(enabled);
HandleToggleChange(resource, enabled, updateSummary: false);
}
UpdateSummary();
}
private void UpdateSummary()
{
if (summaryLabel == null)
{
return;
}
if (allResources.Count == 0)
{
summaryLabel.text = "No MCP resources discovered.";
return;
}
int enabledCount = allResources.Count(r => MCPServiceLocator.ResourceDiscovery.IsResourceEnabled(r.Name));
summaryLabel.text = $"{enabledCount} of {allResources.Count} resources enabled.";
}
private void AddInfoLabel(string message)
{
var label = new Label(message);
label.AddToClassList("help-text");
categoryContainer?.Add(label);
}
private static Label CreateTag(string text)
{
var tag = new Label(text);
tag.AddToClassList("tool-tag");
return tag;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 465cdffd91f9d461caf4298ca322e3ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,15 @@
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
<ui:VisualElement name="resources-section" class="section">
<ui:Label text="Resources" class="section-title" />
<ui:VisualElement class="section-content">
<ui:Label name="resources-summary" class="help-text" text="Discovering resources..." />
<ui:VisualElement name="resources-actions" class="tool-actions">
<ui:Button name="enable-all-resources-button" text="Enable All" class="tool-action-button" />
<ui:Button name="disable-all-resources-button" text="Disable All" class="tool-action-button" />
<ui:Button name="rescan-resources-button" text="Rescan" class="tool-action-button" />
</ui:VisualElement>
<ui:Label name="resources-note" class="help-text" text="Changes apply after reconnecting or re-registering resources." />
<ui:VisualElement name="resource-category-container" class="tool-category-container" />
</ui:VisualElement>
</ui:VisualElement>
</ui:UXML>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: b455fadaaad0a43c4bae9f3fe784c5c3
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@ -8,6 +8,7 @@ using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Windows.Components.Advanced;
using MCPForUnity.Editor.Windows.Components.ClientConfig;
using MCPForUnity.Editor.Windows.Components.Connection;
using MCPForUnity.Editor.Windows.Components.Resources;
using MCPForUnity.Editor.Windows.Components.Tools;
using MCPForUnity.Editor.Windows.Components.Validation;
using UnityEditor;
@ -25,6 +26,7 @@ namespace MCPForUnity.Editor.Windows
private McpValidationSection validationSection;
private McpAdvancedSection advancedSection;
private McpToolsSection toolsSection;
private McpResourcesSection resourcesSection;
// UI Elements
private Label versionLabel;
@ -35,14 +37,17 @@ namespace MCPForUnity.Editor.Windows
private ToolbarToggle validationTabToggle;
private ToolbarToggle advancedTabToggle;
private ToolbarToggle toolsTabToggle;
private ToolbarToggle resourcesTabToggle;
private VisualElement clientsPanel;
private VisualElement validationPanel;
private VisualElement advancedPanel;
private VisualElement toolsPanel;
private VisualElement resourcesPanel;
private static readonly HashSet<MCPForUnityEditorWindow> OpenWindows = new();
private bool guiCreated = false;
private bool toolsLoaded = false;
private bool resourcesLoaded = false;
private double lastRefreshTime = 0;
private const double RefreshDebounceSeconds = 0.5;
@ -51,7 +56,8 @@ namespace MCPForUnity.Editor.Windows
Clients,
Validation,
Advanced,
Tools
Tools,
Resources
}
internal static void CloseAllWindows()
@ -146,12 +152,14 @@ namespace MCPForUnity.Editor.Windows
validationPanel = rootVisualElement.Q<VisualElement>("validation-panel");
advancedPanel = rootVisualElement.Q<VisualElement>("advanced-panel");
toolsPanel = rootVisualElement.Q<VisualElement>("tools-panel");
resourcesPanel = rootVisualElement.Q<VisualElement>("resources-panel");
var clientsContainer = rootVisualElement.Q<VisualElement>("clients-container");
var validationContainer = rootVisualElement.Q<VisualElement>("validation-container");
var advancedContainer = rootVisualElement.Q<VisualElement>("advanced-container");
var toolsContainer = rootVisualElement.Q<VisualElement>("tools-container");
var resourcesContainer = rootVisualElement.Q<VisualElement>("resources-container");
if (clientsPanel == null || validationPanel == null || advancedPanel == null || toolsPanel == null)
if (clientsPanel == null || validationPanel == null || advancedPanel == null || toolsPanel == null || resourcesPanel == null)
{
McpLog.Error("Failed to find tab panels in UXML");
return;
@ -181,6 +189,12 @@ namespace MCPForUnity.Editor.Windows
return;
}
if (resourcesContainer == null)
{
McpLog.Error("Failed to find resources-container in UXML");
return;
}
// Initialize version label
UpdateVersionLabel(EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true));
@ -275,6 +289,26 @@ namespace MCPForUnity.Editor.Windows
McpLog.Warn("Failed to load tools section UXML. Tool configuration will be unavailable.");
}
// Load and initialize Resources section
var resourcesTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
$"{basePath}/Editor/Windows/Components/Resources/McpResourcesSection.uxml"
);
if (resourcesTree != null)
{
var resourcesRoot = resourcesTree.Instantiate();
resourcesContainer.Add(resourcesRoot);
resourcesSection = new McpResourcesSection(resourcesRoot);
if (resourcesTabToggle != null && resourcesTabToggle.value)
{
EnsureResourcesLoaded();
}
}
else
{
McpLog.Warn("Failed to load resources section UXML. Resource configuration will be unavailable.");
}
// Apply .section-last class to last section in each stack
// (Unity UI Toolkit doesn't support :last-child pseudo-class)
ApplySectionLastClasses();
@ -315,6 +349,22 @@ namespace MCPForUnity.Editor.Windows
toolsSection.Refresh();
}
private void EnsureResourcesLoaded()
{
if (resourcesLoaded)
{
return;
}
if (resourcesSection == null)
{
return;
}
resourcesLoaded = true;
resourcesSection.Refresh();
}
/// <summary>
/// Applies the .section-last class to the last .section element in each .section-stack container.
/// This is a workaround for Unity UI Toolkit not supporting the :last-child pseudo-class.
@ -355,6 +405,7 @@ namespace MCPForUnity.Editor.Windows
OpenWindows.Remove(this);
guiCreated = false;
toolsLoaded = false;
resourcesLoaded = false;
}
private void OnFocus()
@ -411,11 +462,13 @@ namespace MCPForUnity.Editor.Windows
validationTabToggle = rootVisualElement.Q<ToolbarToggle>("validation-tab");
advancedTabToggle = rootVisualElement.Q<ToolbarToggle>("advanced-tab");
toolsTabToggle = rootVisualElement.Q<ToolbarToggle>("tools-tab");
resourcesTabToggle = rootVisualElement.Q<ToolbarToggle>("resources-tab");
clientsPanel?.RemoveFromClassList("hidden");
validationPanel?.RemoveFromClassList("hidden");
advancedPanel?.RemoveFromClassList("hidden");
toolsPanel?.RemoveFromClassList("hidden");
resourcesPanel?.RemoveFromClassList("hidden");
if (clientsTabToggle != null)
{
@ -449,6 +502,14 @@ namespace MCPForUnity.Editor.Windows
});
}
if (resourcesTabToggle != null)
{
resourcesTabToggle.RegisterValueChangedCallback(evt =>
{
if (evt.newValue) SwitchPanel(ActivePanel.Resources);
});
}
var savedPanel = EditorPrefs.GetString(EditorPrefKeys.EditorWindowActivePanel, ActivePanel.Clients.ToString());
if (!Enum.TryParse(savedPanel, out ActivePanel initialPanel))
{
@ -481,6 +542,11 @@ namespace MCPForUnity.Editor.Windows
toolsPanel.style.display = DisplayStyle.None;
}
if (resourcesPanel != null)
{
resourcesPanel.style.display = DisplayStyle.None;
}
// Show selected panel
switch (panel)
{
@ -497,6 +563,10 @@ namespace MCPForUnity.Editor.Windows
if (toolsPanel != null) toolsPanel.style.display = DisplayStyle.Flex;
EnsureToolsLoaded();
break;
case ActivePanel.Resources:
if (resourcesPanel != null) resourcesPanel.style.display = DisplayStyle.Flex;
EnsureResourcesLoaded();
break;
}
// Update toggle states
@ -504,6 +574,7 @@ namespace MCPForUnity.Editor.Windows
validationTabToggle?.SetValueWithoutNotify(panel == ActivePanel.Validation);
advancedTabToggle?.SetValueWithoutNotify(panel == ActivePanel.Advanced);
toolsTabToggle?.SetValueWithoutNotify(panel == ActivePanel.Tools);
resourcesTabToggle?.SetValueWithoutNotify(panel == ActivePanel.Resources);
EditorPrefs.SetString(EditorPrefKeys.EditorWindowActivePanel, panel.ToString());
}

View File

@ -10,6 +10,7 @@
<uie:Toolbar name="tab-toolbar" class="tab-toolbar">
<uie:ToolbarToggle name="clients-tab" text="Connect" value="true" />
<uie:ToolbarToggle name="tools-tab" text="Tools" />
<uie:ToolbarToggle name="resources-tab" text="Resources" />
<uie:ToolbarToggle name="validation-tab" text="Scripts" />
<uie:ToolbarToggle name="advanced-tab" text="Advanced" />
</uie:Toolbar>
@ -25,5 +26,8 @@
<ui:ScrollView name="tools-panel" class="panel-scroll hidden" style="flex-grow: 1;">
<ui:VisualElement name="tools-container" class="section-stack" />
</ui:ScrollView>
<ui:ScrollView name="resources-panel" class="panel-scroll hidden" style="flex-grow: 1;">
<ui:VisualElement name="resources-container" class="section-stack" />
</ui:ScrollView>
</ui:VisualElement>
</ui:UXML>

View File

@ -1,4 +1,4 @@
from .models import MCPResponse, UnityInstanceInfo
from .unity_response import normalize_unity_response
from .unity_response import normalize_unity_response, parse_resource_response
__all__ = ['MCPResponse', 'UnityInstanceInfo', 'normalize_unity_response']
__all__ = ['MCPResponse', 'UnityInstanceInfo', 'normalize_unity_response', 'parse_resource_response']

View File

@ -1,7 +1,9 @@
"""Utilities for normalizing Unity transport responses."""
from __future__ import annotations
from typing import Any
from typing import Any, Type
from models.models import MCPResponse
def normalize_unity_response(response: Any) -> Any:
@ -45,3 +47,24 @@ def normalize_unity_response(response: Any) -> Any:
normalized["error"] = message or "Unity command failed"
return normalized
def parse_resource_response(response: Any, typed_cls: Type[MCPResponse]) -> MCPResponse:
"""Parse a Unity response into a typed response class.
Returns a base ``MCPResponse`` for error responses so that typed subclasses
with strict ``data`` fields (e.g. ``list[str]``) don't raise Pydantic
validation errors when ``data`` is ``None``.
"""
if not isinstance(response, dict):
return response
# Detect errors from both normalized (success=False) and raw (status="error") shapes.
if response.get("success") is False or response.get("status") == "error":
return MCPResponse(
success=False,
error=response.get("error"),
message=response.get("message"),
)
return typed_cls(**response)

View File

@ -2,6 +2,7 @@ from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -44,4 +45,4 @@ async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse:
"get_active_tool",
{}
)
return ActiveToolResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, ActiveToolResponse)

View File

@ -1,6 +1,7 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -26,4 +27,4 @@ async def get_layers(ctx: Context) -> LayersResponse | MCPResponse:
"get_layers",
{}
)
return LayersResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, LayersResponse)

View File

@ -1,6 +1,7 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -31,4 +32,4 @@ async def get_menu_items(ctx: Context) -> GetMenuItemsResponse | MCPResponse:
"get_menu_items",
params,
)
return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, GetMenuItemsResponse)

View File

@ -2,6 +2,7 @@ from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -36,4 +37,4 @@ async def get_prefab_stage(ctx: Context) -> PrefabStageResponse | MCPResponse:
"get_prefab_stage",
{}
)
return PrefabStageResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, PrefabStageResponse)

View File

@ -2,6 +2,7 @@ from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -36,4 +37,4 @@ async def get_project_info(ctx: Context) -> ProjectInfoResponse | MCPResponse:
"get_project_info",
{}
)
return ProjectInfoResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, ProjectInfoResponse)

View File

@ -2,6 +2,7 @@ from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -52,4 +53,4 @@ async def get_selection(ctx: Context) -> SelectionResponse | MCPResponse:
"get_selection",
{}
)
return SelectionResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, SelectionResponse)

View File

@ -2,6 +2,7 @@ from pydantic import Field
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -27,4 +28,4 @@ async def get_tags(ctx: Context) -> TagsResponse | MCPResponse:
"get_tags",
{}
)
return TagsResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, TagsResponse)

View File

@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -53,7 +54,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
"get_tests",
{},
)
return GetTestsResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, GetTestsResponse)
@mcp_for_unity_resource(
@ -84,4 +85,4 @@ async def get_tests_for_mode(
"get_tests_for_mode",
{"mode": mode},
)
return GetTestsResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, GetTestsResponse)

View File

@ -2,6 +2,7 @@ from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
@ -44,4 +45,4 @@ async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
"get_windows",
{}
)
return WindowsResponse(**response) if isinstance(response, dict) else response
return parse_resource_response(response, WindowsResponse)