restructured project

- moved Unity package and Python application into separate folders to be
downloaded separately using clone .git/UnityMcpBridge
main
Justin Barnett 2025-04-08 06:14:13 -04:00
parent 9c8ac7a2aa
commit cb603b7b1a
95 changed files with 4862 additions and 3689 deletions

4
.gitignore vendored
View File

@ -14,6 +14,8 @@ build/
dist/
wheels/
*.egg-info
UnityMcpServer/**/*.meta
UnityMcpServer.meta
# Virtual environments
.venv
@ -21,6 +23,8 @@ wheels/
# Unity Editor
*.unitypackage
*.asset
UnityMcpBridge.meta
package.json.meta
# IDE
.idea/

View File

@ -1,12 +0,0 @@
using System;
using Newtonsoft.Json;
namespace UnityMCP.Editor.Models
{
[Serializable]
public class MCPConfigServers
{
[JsonProperty("unityMCP")]
public MCPConfigServer unityMCP;
}
}

View File

@ -1,17 +0,0 @@
namespace UnityMCP.Editor.Models
{
// Enum representing the various status states for MCP clients
public enum McpStatus
{
NotConfigured, // Not set up yet
Configured, // Successfully configured
Running, // Service is running
Connected, // Successfully connected
IncorrectPath, // Configuration has incorrect paths
CommunicationError, // Connected but communication issues
NoResponse, // Connected but not responding
MissingConfig, // Config file exists but missing required elements
UnsupportedOS, // OS is not supported
Error // General error state
}
}

View File

@ -1,959 +0,0 @@
using UnityEngine;
using UnityEditor;
using Newtonsoft.Json.Linq;
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using UnityMCP.Editor.Helpers; // For Response class
using System.Globalization;
namespace UnityMCP.Editor.Tools
{
/// <summary>
/// Handles asset management operations within the Unity project.
/// </summary>
public static class ManageAsset
{
// --- Main Handler ---
// Define the list of valid actions
private static readonly List<string> ValidActions = new List<string>
{
"import", "create", "modify", "delete", "duplicate",
"move", "rename", "search", "get_info", "create_folder",
"get_components"
};
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
// Check if the action is valid before switching
if (!ValidActions.Contains(action))
{
string validActionsList = string.Join(", ", ValidActions);
return Response.Error($"Unknown action: '{action}'. Valid actions are: {validActionsList}");
}
// Common parameters
string path = @params["path"]?.ToString();
try
{
switch (action)
{
case "import":
// Note: Unity typically auto-imports. This might re-import or configure import settings.
return ReimportAsset(path, @params["properties"] as JObject);
case "create":
return CreateAsset(@params);
case "modify":
return ModifyAsset(path, @params["properties"] as JObject);
case "delete":
return DeleteAsset(path);
case "duplicate":
return DuplicateAsset(path, @params["destination"]?.ToString());
case "move": // Often same as rename if within Assets/
case "rename":
return MoveOrRenameAsset(path, @params["destination"]?.ToString());
case "search":
return SearchAssets(@params);
case "get_info":
return GetAssetInfo(path, @params["generatePreview"]?.ToObject<bool>() ?? false);
case "create_folder": // Added specific action for clarity
return CreateFolder(path);
case "get_components":
return GetComponentsFromAsset(path);
default:
// This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications.
string validActionsListDefault = string.Join(", ", ValidActions);
return Response.Error($"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}");
}
}
catch (Exception e)
{
Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}");
return Response.Error($"Internal error processing action '{action}' on '{path}': {e.Message}");
}
}
// --- Action Implementations ---
private static object ReimportAsset(string path, JObject properties)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for reimport.");
string fullPath = SanitizeAssetPath(path);
if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}");
try
{
// TODO: Apply importer properties before reimporting?
// This is complex as it requires getting the AssetImporter, casting it,
// applying properties via reflection or specific methods, saving, then reimporting.
if (properties != null && properties.HasValues)
{
Debug.LogWarning("[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet.");
// AssetImporter importer = AssetImporter.GetAtPath(fullPath);
// if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); }
}
AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate);
// AssetDatabase.Refresh(); // Usually ImportAsset handles refresh
return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath));
}
catch (Exception e)
{
return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}");
}
}
private static object CreateAsset(JObject @params)
{
string path = @params["path"]?.ToString();
string assetType = @params["assetType"]?.ToString();
JObject properties = @params["properties"] as JObject;
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create.");
if (string.IsNullOrEmpty(assetType)) return Response.Error("'assetType' is required for create.");
string fullPath = SanitizeAssetPath(path);
string directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists
if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory)))
{
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory));
AssetDatabase.Refresh(); // Make sure Unity knows about the new folder
}
if (AssetExists(fullPath)) return Response.Error($"Asset already exists at path: {fullPath}");
try
{
UnityEngine.Object newAsset = null;
string lowerAssetType = assetType.ToLowerInvariant();
// Handle common asset types
if (lowerAssetType == "folder")
{
return CreateFolder(path); // Use dedicated method
}
else if (lowerAssetType == "material")
{
Material mat = new Material(Shader.Find("Standard")); // Default shader
// TODO: Apply properties from JObject (e.g., shader name, color, texture assignments)
if(properties != null) ApplyMaterialProperties(mat, properties);
AssetDatabase.CreateAsset(mat, fullPath);
newAsset = mat;
}
else if (lowerAssetType == "scriptableobject")
{
string scriptClassName = properties?["scriptClass"]?.ToString();
if (string.IsNullOrEmpty(scriptClassName)) return Response.Error("'scriptClass' property required when creating ScriptableObject asset.");
Type scriptType = FindType(scriptClassName);
if (scriptType == null || !typeof(ScriptableObject).IsAssignableFrom(scriptType))
{
return Response.Error($"Script class '{scriptClassName}' not found or does not inherit from ScriptableObject.");
}
ScriptableObject so = ScriptableObject.CreateInstance(scriptType);
// TODO: Apply properties from JObject to the ScriptableObject instance?
AssetDatabase.CreateAsset(so, fullPath);
newAsset = so;
}
else if (lowerAssetType == "prefab")
{
// Creating prefabs usually involves saving an existing GameObject hierarchy.
// A common pattern is to create an empty GameObject, configure it, and then save it.
return Response.Error("Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement.");
// Example (conceptual):
// GameObject source = GameObject.Find(properties["sourceGameObject"].ToString());
// if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath);
}
// TODO: Add more asset types (Animation Controller, Scene, etc.)
else
{
// Generic creation attempt (might fail or create empty files)
// For some types, just creating the file might be enough if Unity imports it.
// File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close();
// AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it
// newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath);
return Response.Error($"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject.");
}
if (newAsset == null && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath))) // Check if it wasn't a folder and asset wasn't created
{
return Response.Error($"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details.");
}
AssetDatabase.SaveAssets();
// AssetDatabase.Refresh(); // CreateAsset often handles refresh
return Response.Success($"Asset '{fullPath}' created successfully.", GetAssetData(fullPath));
}
catch (Exception e)
{
return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}");
}
}
private static object CreateFolder(string path)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create_folder.");
string fullPath = SanitizeAssetPath(path);
string parentDir = Path.GetDirectoryName(fullPath);
string folderName = Path.GetFileName(fullPath);
if (AssetExists(fullPath))
{
// Check if it's actually a folder already
if (AssetDatabase.IsValidFolder(fullPath))
{
return Response.Success($"Folder already exists at path: {fullPath}", GetAssetData(fullPath));
}
else
{
return Response.Error($"An asset (not a folder) already exists at path: {fullPath}");
}
}
try
{
// Ensure parent exists
if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) {
// Recursively create parent folders if needed (AssetDatabase handles this internally)
// Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh();
}
string guid = AssetDatabase.CreateFolder(parentDir, folderName);
if (string.IsNullOrEmpty(guid)) {
return Response.Error($"Failed to create folder '{fullPath}'. Check logs and permissions.");
}
// AssetDatabase.Refresh(); // CreateFolder usually handles refresh
return Response.Success($"Folder '{fullPath}' created successfully.", GetAssetData(fullPath));
}
catch (Exception e)
{
return Response.Error($"Failed to create folder '{fullPath}': {e.Message}");
}
}
private static object ModifyAsset(string path, JObject properties)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for modify.");
if (properties == null || !properties.HasValues) return Response.Error("'properties' are required for modify.");
string fullPath = SanitizeAssetPath(path);
if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}");
try
{
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath);
if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}");
bool modified = false; // Flag to track if any changes were made
// --- NEW: Handle GameObject / Prefab Component Modification ---
if (asset is GameObject gameObject)
{
// Iterate through the properties JSON: keys are component names, values are properties objects for that component
foreach (var prop in properties.Properties())
{
string componentName = prop.Name; // e.g., "Collectible"
// Check if the value associated with the component name is actually an object containing properties
if (prop.Value is JObject componentProperties && componentProperties.HasValues) // e.g., {"bobSpeed": 2.0}
{
// Find the component on the GameObject using the name from the JSON key
// Using GetComponent(string) is convenient but might require exact type name or be ambiguous.
// Consider using FindType helper if needed for more complex scenarios.
Component targetComponent = gameObject.GetComponent(componentName);
if (targetComponent != null)
{
// Apply the nested properties (e.g., bobSpeed) to the found component instance
// Use |= to ensure 'modified' becomes true if any component is successfully modified
modified |= ApplyObjectProperties(targetComponent, componentProperties);
}
else
{
// Log a warning if a specified component couldn't be found
Debug.LogWarning($"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component.");
}
}
else
{
// Log a warning if the structure isn't {"ComponentName": {"prop": value}}
// We could potentially try to apply this property directly to the GameObject here if needed,
// but the primary goal is component modification.
Debug.LogWarning($"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping.");
}
}
// Note: 'modified' is now true if ANY component property was successfully changed.
}
// --- End NEW ---
// --- Existing logic for other asset types (now as else-if) ---
// Example: Modifying a Material
else if (asset is Material material)
{
// Apply properties directly to the material. If this modifies, it sets modified=true.
// Use |= in case the asset was already marked modified by previous logic (though unlikely here)
modified |= ApplyMaterialProperties(material, properties);
}
// Example: Modifying a ScriptableObject
else if (asset is ScriptableObject so)
{
// Apply properties directly to the ScriptableObject.
modified |= ApplyObjectProperties(so, properties); // General helper
}
// Example: Modifying TextureImporter settings
else if (asset is Texture) {
AssetImporter importer = AssetImporter.GetAtPath(fullPath);
if (importer is TextureImporter textureImporter)
{
bool importerModified = ApplyObjectProperties(textureImporter, properties);
if (importerModified) {
// Importer settings need saving and reimporting
AssetDatabase.WriteImportSettingsIfDirty(fullPath);
AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes
modified = true; // Mark overall operation as modified
}
}
else {
Debug.LogWarning($"Could not get TextureImporter for {fullPath}.");
}
}
// TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.)
else // Fallback for other asset types OR direct properties on non-GameObject assets
{
// This block handles non-GameObject/Material/ScriptableObject/Texture assets.
// Attempts to apply properties directly to the asset itself.
Debug.LogWarning($"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself.");
modified |= ApplyObjectProperties(asset, properties);
}
// --- End Existing Logic ---
// Check if any modification happened (either component or direct asset modification)
if (modified)
{
// Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it.
EditorUtility.SetDirty(asset);
// Save all modified assets to disk.
AssetDatabase.SaveAssets();
// Refresh might be needed in some edge cases, but SaveAssets usually covers it.
// AssetDatabase.Refresh();
return Response.Success($"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath));
} else {
// If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed.
return Response.Success($"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", GetAssetData(fullPath));
// Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath));
}
}
catch (Exception e)
{
// Log the detailed error internally
Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}");
// Return a user-friendly error message
return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}");
}
}
private static object DeleteAsset(string path)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for delete.");
string fullPath = SanitizeAssetPath(path);
if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}");
try
{
bool success = AssetDatabase.DeleteAsset(fullPath);
if (success)
{
// AssetDatabase.Refresh(); // DeleteAsset usually handles refresh
return Response.Success($"Asset '{fullPath}' deleted successfully.");
}
else
{
// This might happen if the file couldn't be deleted (e.g., locked)
return Response.Error($"Failed to delete asset '{fullPath}'. Check logs or if the file is locked.");
}
}
catch (Exception e)
{
return Response.Error($"Error deleting asset '{fullPath}': {e.Message}");
}
}
private static object DuplicateAsset(string path, string destinationPath)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for duplicate.");
string sourcePath = SanitizeAssetPath(path);
if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}");
string destPath;
if (string.IsNullOrEmpty(destinationPath))
{
// Generate a unique path if destination is not provided
destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath);
}
else
{
destPath = SanitizeAssetPath(destinationPath);
if (AssetExists(destPath)) return Response.Error($"Asset already exists at destination path: {destPath}");
// Ensure destination directory exists
EnsureDirectoryExists(Path.GetDirectoryName(destPath));
}
try
{
bool success = AssetDatabase.CopyAsset(sourcePath, destPath);
if (success)
{
// AssetDatabase.Refresh();
return Response.Success($"Asset '{sourcePath}' duplicated to '{destPath}'.", GetAssetData(destPath));
}
else
{
return Response.Error($"Failed to duplicate asset from '{sourcePath}' to '{destPath}'.");
}
}
catch (Exception e)
{
return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}");
}
}
private static object MoveOrRenameAsset(string path, string destinationPath)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for move/rename.");
if (string.IsNullOrEmpty(destinationPath)) return Response.Error("'destination' path is required for move/rename.");
string sourcePath = SanitizeAssetPath(path);
string destPath = SanitizeAssetPath(destinationPath);
if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}");
if (AssetExists(destPath)) return Response.Error($"An asset already exists at the destination path: {destPath}");
// Ensure destination directory exists
EnsureDirectoryExists(Path.GetDirectoryName(destPath));
try
{
// Validate will return an error string if failed, null if successful
string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath);
if (!string.IsNullOrEmpty(error))
{
return Response.Error($"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}");
}
string guid = AssetDatabase.MoveAsset(sourcePath, destPath);
if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success
{
// AssetDatabase.Refresh(); // MoveAsset usually handles refresh
return Response.Success($"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", GetAssetData(destPath));
}
else
{
// This case might not be reachable if ValidateMoveAsset passes, but good to have
return Response.Error($"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'.");
}
}
catch (Exception e)
{
return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}");
}
}
private static object SearchAssets(JObject @params)
{
string searchPattern = @params["searchPattern"]?.ToString();
string filterType = @params["filterType"]?.ToString();
string pathScope = @params["path"]?.ToString(); // Use path as folder scope
string filterDateAfterStr = @params["filterDateAfter"]?.ToString();
int pageSize = @params["pageSize"]?.ToObject<int?>() ?? 50; // Default page size
int pageNumber = @params["pageNumber"]?.ToObject<int?>() ?? 1; // Default page number (1-based)
bool generatePreview = @params["generatePreview"]?.ToObject<bool>() ?? false;
List<string> searchFilters = new List<string>();
if (!string.IsNullOrEmpty(searchPattern)) searchFilters.Add(searchPattern);
if (!string.IsNullOrEmpty(filterType)) searchFilters.Add($"t:{filterType}");
string[] folderScope = null;
if (!string.IsNullOrEmpty(pathScope))
{
folderScope = new string[] { SanitizeAssetPath(pathScope) };
if (!AssetDatabase.IsValidFolder(folderScope[0])) {
// Maybe the user provided a file path instead of a folder?
// We could search in the containing folder, or return an error.
Debug.LogWarning($"Search path '{folderScope[0]}' is not a valid folder. Searching entire project.");
folderScope = null; // Search everywhere if path isn't a folder
}
}
DateTime? filterDateAfter = null;
if (!string.IsNullOrEmpty(filterDateAfterStr)) {
if (DateTime.TryParse(filterDateAfterStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime parsedDate)) {
filterDateAfter = parsedDate;
} else {
Debug.LogWarning($"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format.");
}
}
try
{
string[] guids = AssetDatabase.FindAssets(string.Join(" ", searchFilters), folderScope);
List<object> results = new List<object>();
int totalFound = 0;
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(assetPath)) continue;
// Apply date filter if present
if (filterDateAfter.HasValue) {
DateTime lastWriteTime = File.GetLastWriteTimeUtc(Path.Combine(Directory.GetCurrentDirectory(), assetPath));
if (lastWriteTime <= filterDateAfter.Value) {
continue; // Skip assets older than or equal to the filter date
}
}
totalFound++; // Count matching assets before pagination
results.Add(GetAssetData(assetPath, generatePreview));
}
// Apply pagination
int startIndex = (pageNumber - 1) * pageSize;
var pagedResults = results.Skip(startIndex).Take(pageSize).ToList();
return Response.Success($"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", new {
totalAssets = totalFound,
pageSize = pageSize,
pageNumber = pageNumber,
assets = pagedResults
});
}
catch (Exception e)
{
return Response.Error($"Error searching assets: {e.Message}");
}
}
private static object GetAssetInfo(string path, bool generatePreview)
{
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_info.");
string fullPath = SanitizeAssetPath(path);
if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}");
try
{
return Response.Success("Asset info retrieved.", GetAssetData(fullPath, generatePreview));
}
catch (Exception e)
{
return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}");
}
}
/// <summary>
/// Retrieves components attached to a GameObject asset (like a Prefab).
/// </summary>
/// <param name="path">The asset path of the GameObject or Prefab.</param>
/// <returns>A response object containing a list of component type names or an error.</returns>
private static object GetComponentsFromAsset(string path)
{
// 1. Validate input path
if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_components.");
// 2. Sanitize and check existence
string fullPath = SanitizeAssetPath(path);
if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}");
try
{
// 3. Load the asset
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath);
if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}");
// 4. Check if it's a GameObject (Prefabs load as GameObjects)
GameObject gameObject = asset as GameObject;
if (gameObject == null)
{
// Also check if it's *directly* a Component type (less common for primary assets)
Component componentAsset = asset as Component;
if (componentAsset != null) {
// If the asset itself *is* a component, maybe return just its info?
// This is an edge case. Let's stick to GameObjects for now.
return Response.Error($"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject.");
}
return Response.Error($"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type.");
}
// 5. Get components
Component[] components = gameObject.GetComponents<Component>();
// 6. Format component data
List<object> componentList = components.Select(comp => new {
typeName = comp.GetType().FullName,
instanceID = comp.GetInstanceID(),
// TODO: Add more component-specific details here if needed in the future?
// Requires reflection or specific handling per component type.
}).ToList<object>(); // Explicit cast for clarity if needed
// 7. Return success response
return Response.Success($"Found {componentList.Count} component(s) on asset '{fullPath}'.", componentList);
}
catch (Exception e)
{
Debug.LogError($"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}");
return Response.Error($"Error getting components for asset '{fullPath}': {e.Message}");
}
}
// --- Internal Helpers ---
/// <summary>
/// Ensures the asset path starts with "Assets/".
/// </summary>
private static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path)) return path;
path = path.Replace('\\', '/'); // Normalize separators
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
/// <summary>
/// Checks if an asset exists at the given path (file or folder).
/// </summary>
private static bool AssetExists(string sanitizedPath)
{
// AssetDatabase APIs are generally preferred over raw File/Directory checks for assets.
// Check if it's a known asset GUID.
if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)))
{
return true;
}
// AssetPathToGUID might not work for newly created folders not yet refreshed.
// Check directory explicitly for folders.
if(Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) {
// Check if it's considered a *valid* folder by Unity
return AssetDatabase.IsValidFolder(sanitizedPath);
}
// Check file existence for non-folder assets.
if(File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))){
return true; // Assume if file exists, it's an asset or will be imported
}
return false;
// Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath));
}
/// <summary>
/// Ensures the directory for a given asset path exists, creating it if necessary.
/// </summary>
private static void EnsureDirectoryExists(string directoryPath)
{
if (string.IsNullOrEmpty(directoryPath)) return;
string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath);
if (!Directory.Exists(fullDirPath))
{
Directory.CreateDirectory(fullDirPath);
AssetDatabase.Refresh(); // Let Unity know about the new folder
}
}
/// <summary>
/// Applies properties from JObject to a Material.
/// </summary>
private static bool ApplyMaterialProperties(Material mat, JObject properties)
{
if (mat == null || properties == null) return false;
bool modified = false;
// Example: Set shader
if (properties["shader"]?.Type == JTokenType.String) {
Shader newShader = Shader.Find(properties["shader"].ToString());
if (newShader != null && mat.shader != newShader) {
mat.shader = newShader;
modified = true;
}
}
// Example: Set color property
if (properties["color"] is JObject colorProps) {
string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color
if (colorProps["value"] is JArray colArr && colArr.Count >= 3) {
try {
Color newColor = new Color(
colArr[0].ToObject<float>(),
colArr[1].ToObject<float>(),
colArr[2].ToObject<float>(),
colArr.Count > 3 ? colArr[3].ToObject<float>() : 1.0f
);
if(mat.HasProperty(propName) && mat.GetColor(propName) != newColor) {
mat.SetColor(propName, newColor);
modified = true;
}
} catch (Exception ex) { Debug.LogWarning($"Error parsing color property '{propName}': {ex.Message}"); }
}
}
// Example: Set float property
if (properties["float"] is JObject floatProps) {
string propName = floatProps["name"]?.ToString();
if (!string.IsNullOrEmpty(propName) && floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) {
try {
float newVal = floatProps["value"].ToObject<float>();
if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) {
mat.SetFloat(propName, newVal);
modified = true;
}
} catch (Exception ex) { Debug.LogWarning($"Error parsing float property '{propName}': {ex.Message}"); }
}
}
// Example: Set texture property
if (properties["texture"] is JObject texProps) {
string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture
string texPath = texProps["path"]?.ToString();
if (!string.IsNullOrEmpty(texPath)) {
Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>(SanitizeAssetPath(texPath));
if (newTex != null && mat.HasProperty(propName) && mat.GetTexture(propName) != newTex) {
mat.SetTexture(propName, newTex);
modified = true;
}
else if(newTex == null) {
Debug.LogWarning($"Texture not found at path: {texPath}");
}
}
}
// TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.)
return modified;
}
/// <summary>
/// Generic helper to set properties on any UnityEngine.Object using reflection.
/// </summary>
private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties)
{
if (target == null || properties == null) return false;
bool modified = false;
Type type = target.GetType();
foreach (var prop in properties.Properties())
{
string propName = prop.Name;
JToken propValue = prop.Value;
if (SetPropertyOrField(target, propName, propValue, type))
{
modified = true;
}
}
return modified;
}
/// <summary>
/// Helper to set a property or field via reflection, handling basic types and Unity objects.
/// </summary>
private static bool SetPropertyOrField(object target, string memberName, JToken value, Type type = null)
{
type = type ?? target.GetType();
System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase;
try
{
System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags);
if (propInfo != null && propInfo.CanWrite)
{
object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType);
if (convertedValue != null && !object.Equals(propInfo.GetValue(target), convertedValue))
{
propInfo.SetValue(target, convertedValue);
return true;
}
}
else
{
System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags);
if (fieldInfo != null)
{
object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType);
if (convertedValue != null && !object.Equals(fieldInfo.GetValue(target), convertedValue))
{
fieldInfo.SetValue(target, convertedValue);
return true;
}
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}");
}
return false;
}
/// <summary>
/// Simple JToken to Type conversion for common Unity types and primitives.
/// </summary>
private static object ConvertJTokenToType(JToken token, Type targetType)
{
try
{
if (token == null || token.Type == JTokenType.Null) return null;
if (targetType == typeof(string)) return token.ToObject<string>();
if (targetType == typeof(int)) return token.ToObject<int>();
if (targetType == typeof(float)) return token.ToObject<float>();
if (targetType == typeof(bool)) return token.ToObject<bool>();
if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2)
return new Vector2(arrV2[0].ToObject<float>(), arrV2[1].ToObject<float>());
if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3)
return new Vector3(arrV3[0].ToObject<float>(), arrV3[1].ToObject<float>(), arrV3[2].ToObject<float>());
if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4)
return new Vector4(arrV4[0].ToObject<float>(), arrV4[1].ToObject<float>(), arrV4[2].ToObject<float>(), arrV4[3].ToObject<float>());
if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4)
return new Quaternion(arrQ[0].ToObject<float>(), arrQ[1].ToObject<float>(), arrQ[2].ToObject<float>(), arrQ[3].ToObject<float>());
if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA
return new Color(arrC[0].ToObject<float>(), arrC[1].ToObject<float>(), arrC[2].ToObject<float>(), arrC.Count > 3 ? arrC[3].ToObject<float>() : 1.0f);
if (targetType.IsEnum)
return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing
// Handle loading Unity Objects (Materials, Textures, etc.) by path
if (typeof(UnityEngine.Object).IsAssignableFrom(targetType) && token.Type == JTokenType.String)
{
string assetPath = SanitizeAssetPath(token.ToString());
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType);
if (loadedAsset == null) {
Debug.LogWarning($"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}");
}
return loadedAsset;
}
// Fallback: Try direct conversion (might work for other simple value types)
return token.ToObject(targetType);
}
catch (Exception ex)
{
Debug.LogWarning($"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}");
return null;
}
}
/// <summary>
/// Helper to find a Type by name, searching relevant assemblies.
/// Needed for creating ScriptableObjects or finding component types by name.
/// </summary>
private static Type FindType(string typeName)
{
if (string.IsNullOrEmpty(typeName)) return null;
// Try direct lookup first (common Unity types often don't need assembly qualified name)
var type = Type.GetType(typeName) ??
Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule") ??
Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI") ??
Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule");
if (type != null) return type;
// If not found, search loaded assemblies (slower but more robust for user scripts)
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
// Look for non-namespaced first
type = assembly.GetType(typeName, false, true); // throwOnError=false, ignoreCase=true
if (type != null) return type;
// Check common namespaces if simple name given
type = assembly.GetType("UnityEngine." + typeName, false, true);
if (type != null) return type;
type = assembly.GetType("UnityEditor." + typeName, false, true);
if (type != null) return type;
// Add other likely namespaces if needed (e.g., specific plugins)
}
Debug.LogWarning($"[FindType] Type '{typeName}' not found in any loaded assembly.");
return null; // Not found
}
// --- Data Serialization ---
/// <summary>
/// Creates a serializable representation of an asset.
/// </summary>
private static object GetAssetData(string path, bool generatePreview = false)
{
if (string.IsNullOrEmpty(path) || !AssetExists(path)) return null;
string guid = AssetDatabase.AssetPathToGUID(path);
Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path);
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
string previewBase64 = null;
int previewWidth = 0;
int previewHeight = 0;
if (generatePreview && asset != null)
{
Texture2D preview = AssetPreview.GetAssetPreview(asset);
if (preview != null)
{
try {
// Ensure texture is readable for EncodeToPNG
// Creating a temporary readable copy is safer
RenderTexture rt = RenderTexture.GetTemporary(preview.width, preview.height);
Graphics.Blit(preview, rt);
RenderTexture previous = RenderTexture.active;
RenderTexture.active = rt;
Texture2D readablePreview = new Texture2D(preview.width, preview.height);
readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
readablePreview.Apply();
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(rt);
byte[] pngData = readablePreview.EncodeToPNG();
previewBase64 = Convert.ToBase64String(pngData);
previewWidth = readablePreview.width;
previewHeight = readablePreview.height;
UnityEngine.Object.DestroyImmediate(readablePreview); // Clean up temp texture
} catch (Exception ex) {
Debug.LogWarning($"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable.");
// Fallback: Try getting static preview if available?
// Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset);
}
}
else
{
Debug.LogWarning($"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?");
}
}
return new
{
path = path,
guid = guid,
assetType = assetType?.FullName ?? "Unknown",
name = Path.GetFileNameWithoutExtension(path),
fileName = Path.GetFileName(path),
isFolder = AssetDatabase.IsValidFolder(path),
instanceID = asset?.GetInstanceID() ?? 0,
lastWriteTimeUtc = File.GetLastWriteTimeUtc(Path.Combine(Directory.GetCurrentDirectory(), path)).ToString("o"), // ISO 8601
// --- Preview Data ---
previewBase64 = previewBase64, // PNG data as Base64 string
previewWidth = previewWidth,
previewHeight = previewHeight
// TODO: Add more metadata? Importer settings? Dependencies?
};
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,397 +0,0 @@
using UnityEngine;
using UnityEditor;
using UnityEditorInternal;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityMCP.Editor.Helpers; // For Response class
using System.Globalization;
namespace UnityMCP.Editor.Tools
{
/// <summary>
/// Handles reading and clearing Unity Editor console log entries.
/// Uses reflection to access internal LogEntry methods/properties.
/// </summary>
public static class ReadConsole
{
// Reflection members for accessing internal LogEntry data
// private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection
private static MethodInfo _startGettingEntriesMethod;
private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End...
private static MethodInfo _clearMethod;
private static MethodInfo _getCountMethod;
private static MethodInfo _getEntryMethod;
private static FieldInfo _modeField;
private static FieldInfo _messageField;
private static FieldInfo _fileField;
private static FieldInfo _lineField;
private static FieldInfo _instanceIdField;
// Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative?
// Static constructor for reflection setup
static ReadConsole()
{
try {
Type logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries");
if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries");
// Include NonPublic binding flags as internal APIs might change accessibility
BindingFlags staticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
BindingFlags instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
_startGettingEntriesMethod = logEntriesType.GetMethod("StartGettingEntries", staticFlags);
if (_startGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.StartGettingEntries");
// Try reflecting EndGettingEntries based on warning message
_endGettingEntriesMethod = logEntriesType.GetMethod("EndGettingEntries", staticFlags);
if (_endGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.EndGettingEntries");
_clearMethod = logEntriesType.GetMethod("Clear", staticFlags);
if (_clearMethod == null) throw new Exception("Failed to reflect LogEntries.Clear");
_getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags);
if (_getCountMethod == null) throw new Exception("Failed to reflect LogEntries.GetCount");
_getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags);
if (_getEntryMethod == null) throw new Exception("Failed to reflect LogEntries.GetEntryInternal");
Type logEntryType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntry");
if (logEntryType == null) throw new Exception("Could not find internal type UnityEditor.LogEntry");
_modeField = logEntryType.GetField("mode", instanceFlags);
if (_modeField == null) throw new Exception("Failed to reflect LogEntry.mode");
_messageField = logEntryType.GetField("message", instanceFlags);
if (_messageField == null) throw new Exception("Failed to reflect LogEntry.message");
_fileField = logEntryType.GetField("file", instanceFlags);
if (_fileField == null) throw new Exception("Failed to reflect LogEntry.file");
_lineField = logEntryType.GetField("line", instanceFlags);
if (_lineField == null) throw new Exception("Failed to reflect LogEntry.line");
_instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
if (_instanceIdField == null) throw new Exception("Failed to reflect LogEntry.instanceID");
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}");
// Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this.
_startGettingEntriesMethod = _endGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null;
_modeField = _messageField = _fileField = _lineField = _instanceIdField = null;
}
}
// --- Main Handler ---
public static object HandleCommand(JObject @params)
{
// Check if ALL required reflection members were successfully initialized.
if (_startGettingEntriesMethod == null || _endGettingEntriesMethod == null ||
_clearMethod == null || _getCountMethod == null || _getEntryMethod == null ||
_modeField == null || _messageField == null || _fileField == null || _lineField == null || _instanceIdField == null)
{
// Log the error here as well for easier debugging in Unity Console
Debug.LogError("[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue.");
return Response.Error("ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs.");
}
string action = @params["action"]?.ToString().ToLower() ?? "get";
try
{
if (action == "clear")
{
return ClearConsole();
}
else if (action == "get")
{
// Extract parameters for 'get'
var types = (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() ?? new List<string> { "error", "warning", "log" };
int? count = @params["count"]?.ToObject<int?>();
string filterText = @params["filterText"]?.ToString();
string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering
string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
bool includeStacktrace = @params["includeStacktrace"]?.ToObject<bool?>() ?? true;
if (types.Contains("all")) {
types = new List<string> { "error", "warning", "log" }; // Expand 'all'
}
if (!string.IsNullOrEmpty(sinceTimestampStr))
{
Debug.LogWarning("[ReadConsole] Filtering by 'since_timestamp' is not currently implemented.");
// Need a way to get timestamp per log entry.
}
return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
}
else
{
return Response.Error($"Unknown action: '{action}'. Valid actions are 'get' or 'clear'.");
}
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}");
return Response.Error($"Internal error processing action '{action}': {e.Message}");
}
}
// --- Action Implementations ---
private static object ClearConsole()
{
try
{
_clearMethod.Invoke(null, null); // Static method, no instance, no parameters
return Response.Success("Console cleared successfully.");
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Failed to clear console: {e}");
return Response.Error($"Failed to clear console: {e.Message}");
}
}
private static object GetConsoleEntries(List<string> types, int? count, string filterText, string format, bool includeStacktrace)
{
List<object> formattedEntries = new List<object>();
int retrievedCount = 0;
try
{
// LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal
_startGettingEntriesMethod.Invoke(null, null);
int totalEntries = (int)_getCountMethod.Invoke(null, null);
// Create instance to pass to GetEntryInternal - Ensure the type is correct
Type logEntryType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntry");
if (logEntryType == null) throw new Exception("Could not find internal type UnityEditor.LogEntry during GetConsoleEntries.");
object logEntryInstance = Activator.CreateInstance(logEntryType);
for (int i = 0; i < totalEntries; i++)
{
// Get the entry data into our instance using reflection
_getEntryMethod.Invoke(null, new object[] { i, logEntryInstance });
// Extract data using reflection
int mode = (int)_modeField.GetValue(logEntryInstance);
string message = (string)_messageField.GetValue(logEntryInstance);
string file = (string)_fileField.GetValue(logEntryInstance);
int line = (int)_lineField.GetValue(logEntryInstance);
// int instanceId = (int)_instanceIdField.GetValue(logEntryInstance);
if (string.IsNullOrEmpty(message)) continue; // Skip empty messages
// --- Filtering ---
// Filter by type
LogType currentType = GetLogTypeFromMode(mode);
if (!types.Contains(currentType.ToString().ToLowerInvariant()))
{
continue;
}
// Filter by text (case-insensitive)
if (!string.IsNullOrEmpty(filterText) && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
// TODO: Filter by timestamp (requires timestamp data)
// --- Formatting ---
string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null;
// Get first line if stack is present and requested, otherwise use full message
string messageOnly = (includeStacktrace && !string.IsNullOrEmpty(stackTrace)) ? message.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)[0] : message;
object formattedEntry = null;
switch (format)
{
case "plain":
formattedEntry = messageOnly;
break;
case "json":
case "detailed": // Treat detailed as json for structured return
default:
formattedEntry = new {
type = currentType.ToString(),
message = messageOnly,
file = file,
line = line,
// timestamp = "", // TODO
stackTrace = stackTrace // Will be null if includeStacktrace is false or no stack found
};
break;
}
formattedEntries.Add(formattedEntry);
retrievedCount++;
// Apply count limit (after filtering)
if (count.HasValue && retrievedCount >= count.Value)
{
break;
}
}
}
catch (Exception e) {
Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
// Ensure EndGettingEntries is called even if there's an error during iteration
try { _endGettingEntriesMethod.Invoke(null, null); } catch { /* Ignore nested exception */ }
return Response.Error($"Error retrieving log entries: {e.Message}");
}
finally
{
// Ensure we always call EndGettingEntries
try { _endGettingEntriesMethod.Invoke(null, null); } catch (Exception e) {
Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}");
// Don't return error here as we might have valid data, but log it.
}
}
// Return the filtered and formatted list (might be empty)
return Response.Success($"Retrieved {formattedEntries.Count} log entries.", formattedEntries);
}
// --- Internal Helpers ---
// Mapping from LogEntry.mode bits to LogType enum
// Based on decompiled UnityEditor code or common patterns. Precise bits might change between Unity versions.
// See comments below for LogEntry mode bits exploration.
// Note: This mapping is simplified and might not cover all edge cases or future Unity versions perfectly.
private const int ModeBitError = 1 << 0;
private const int ModeBitAssert = 1 << 1;
private const int ModeBitWarning = 1 << 2;
private const int ModeBitLog = 1 << 3;
private const int ModeBitException = 1 << 4; // Often combined with Error bits
private const int ModeBitScriptingError = 1 << 9;
private const int ModeBitScriptingWarning = 1 << 10;
private const int ModeBitScriptingLog = 1 << 11;
private const int ModeBitScriptingException = 1 << 18;
private const int ModeBitScriptingAssertion = 1 << 22;
private static LogType GetLogTypeFromMode(int mode)
{
// First, determine the type based on the original logic (most severe first)
LogType initialType;
if ((mode & (ModeBitError | ModeBitScriptingError | ModeBitException | ModeBitScriptingException)) != 0) {
initialType = LogType.Error;
}
else if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) {
initialType = LogType.Assert;
}
else if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) {
initialType = LogType.Warning;
}
else {
initialType = LogType.Log;
}
// Apply the observed "one level lower" correction
switch (initialType)
{
case LogType.Error: return LogType.Warning; // Error becomes Warning
case LogType.Warning: return LogType.Log; // Warning becomes Log
case LogType.Assert: return LogType.Assert; // Assert remains Assert (no lower level defined)
case LogType.Log: return LogType.Log; // Log remains Log
default: return LogType.Log; // Default fallback
}
}
/// <summary>
/// Attempts to extract the stack trace part from a log message.
/// Unity log messages often have the stack trace appended after the main message,
/// starting on a new line and typically indented or beginning with "at ".
/// </summary>
/// <param name="fullMessage">The complete log message including potential stack trace.</param>
/// <returns>The extracted stack trace string, or null if none is found.</returns>
private static string ExtractStackTrace(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage)) return null;
// Split into lines, removing empty ones to handle different line endings gracefully.
// Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here.
string[] lines = fullMessage.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
// If there's only one line or less, there's no separate stack trace.
if (lines.Length <= 1) return null;
int stackStartIndex = -1;
// Start checking from the second line onwards.
for(int i = 1; i < lines.Length; ++i)
{
// Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical.
string trimmedLine = lines[i].TrimStart();
// Check for common stack trace patterns.
if (trimmedLine.StartsWith("at ") ||
trimmedLine.StartsWith("UnityEngine.") ||
trimmedLine.StartsWith("UnityEditor.") ||
trimmedLine.Contains("(at ") || // Covers "(at Assets/..." pattern
// Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
(trimmedLine.Length > 0 && char.IsUpper(trimmedLine[0]) && trimmedLine.Contains('.'))
)
{
stackStartIndex = i;
break; // Found the likely start of the stack trace
}
}
// If a potential start index was found...
if (stackStartIndex > 0)
{
// Join the lines from the stack start index onwards using standard newline characters.
// This reconstructs the stack trace part of the message.
return string.Join("\n", lines.Skip(stackStartIndex));
}
// No clear stack trace found based on the patterns.
return null;
}
/* LogEntry.mode bits exploration (based on Unity decompilation/observation):
May change between versions.
Basic Types:
kError = 1 << 0 (1)
kAssert = 1 << 1 (2)
kWarning = 1 << 2 (4)
kLog = 1 << 3 (8)
kFatal = 1 << 4 (16) - Often treated as Exception/Error
Modifiers/Context:
kAssetImportError = 1 << 7 (128)
kAssetImportWarning = 1 << 8 (256)
kScriptingError = 1 << 9 (512)
kScriptingWarning = 1 << 10 (1024)
kScriptingLog = 1 << 11 (2048)
kScriptCompileError = 1 << 12 (4096)
kScriptCompileWarning = 1 << 13 (8192)
kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play
kMayIgnoreLineNumber = 1 << 15 (32768)
kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button
kDisplayPreviousErrorInStatusBar = 1 << 17 (131072)
kScriptingException = 1 << 18 (262144)
kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI
kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior
kGraphCompileError = 1 << 21 (2097152)
kScriptingAssertion = 1 << 22 (4194304)
kVisualScriptingError = 1 << 23 (8388608)
Example observed values:
Log: 2048 (ScriptingLog) or 8 (Log)
Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning)
Error: 513 (ScriptingError | Error) or 1 (Error)
Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination
Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert)
*/
}
}

View File

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

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 41c60e5ab0e41d84ba997afc471ac58a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: bbc163679c5bb0f418c6f6af1fa50f3a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ac3ad17989088c24598726ec3e0a53ba
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 872659ff7f5d9294ca6d47e93f6a111f
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 8865a8f86cc0a3240b94504bd2e5c0be
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 67b82e49c36517040b7cfea8e421764e
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 27ffc6de0e9253e4f980ae545f07731a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: a2f972b61922666418f99fa8f8ba817e
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: b34907e09ab90854fa849302b96c6247
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: be712c04494a1874593719eeb2a882ac
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: beb93a353b9140c44b7ac22d2bb8481a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: c94ba17ca2284764f99d61356c5feded
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: c72711b644ecf0d40945ddba9b4bce77
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 37728a13ca38f894b8760d808a909148
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 78cb4b2703910bc44b4dafad25cf8b35
guid: 31e7fac5858840340a75cc6df0ad3d9e
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@ -1,6 +1,6 @@
using UnityMCP.Editor.Models;
using UnityMcpBridge.Editor.Models;
namespace UnityMCP.Editor.Data
namespace UnityMcpBridge.Editor.Data
{
public class DefaultServerConfig : ServerConfig
{
@ -15,3 +15,4 @@ namespace UnityMCP.Editor.Data
public new float retryDelay = 1.0f;
}
}

View File

@ -1,14 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityMCP.Editor.Models;
using UnityMcpBridge.Editor.Models;
namespace UnityMCP.Editor.Data
namespace UnityMcpBridge.Editor.Data
{
public class McpClients
{
public List<McpClient> clients = new() {
new() {
public List<McpClient> clients = new()
{
new()
{
name = "Claude Desktop",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@ -23,9 +25,10 @@ namespace UnityMCP.Editor.Data
"claude_desktop_config.json"
),
mcpType = McpTypes.ClaudeDesktop,
configStatus = "Not Configured"
configStatus = "Not Configured",
},
new() {
new()
{
name = "Cursor",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
@ -38,8 +41,8 @@ namespace UnityMCP.Editor.Data
"mcp.json"
),
mcpType = McpTypes.Cursor,
configStatus = "Not Configured"
}
configStatus = "Not Configured",
},
};
// Initialize status enums after construction
@ -55,3 +58,4 @@ namespace UnityMCP.Editor.Data
}
}
}

View File

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace UnityMCP.Editor.Helpers
namespace UnityMcpBridge.Editor.Helpers
{
/// <summary>
/// Provides static methods for creating standardized success and error response objects.
@ -19,7 +19,12 @@ namespace UnityMCP.Editor.Helpers
{
if (data != null)
{
return new { success = true, message = message, data = data };
return new
{
success = true,
message = message,
data = data,
};
}
else
{
@ -38,7 +43,12 @@ namespace UnityMCP.Editor.Helpers
if (data != null)
{
// Note: The key is "error" for error messages, not "message"
return new { success = false, error = errorMessage, data = data };
return new
{
success = false,
error = errorMessage,
data = data,
};
}
else
{
@ -47,3 +57,4 @@ namespace UnityMCP.Editor.Helpers
}
}
}

View File

@ -1,7 +1,7 @@
using UnityEngine;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace UnityMCP.Editor.Helpers
namespace UnityMcpBridge.Editor.Helpers
{
/// <summary>
/// Helper class for Vector3 operations
@ -22,3 +22,4 @@ namespace UnityMCP.Editor.Helpers
}
}
}

View File

@ -1,6 +1,6 @@
using Newtonsoft.Json.Linq;
namespace UnityMCP.Editor.Models
namespace UnityMcpBridge.Editor.Models
{
/// <summary>
/// Represents a command received from the MCP client
@ -18,3 +18,4 @@ namespace UnityMCP.Editor.Models
public JObject @params { get; set; }
}
}

View File

@ -1,10 +1,10 @@
using System;
using Newtonsoft.Json;
namespace UnityMCP.Editor.Models
namespace UnityMcpBridge.Editor.Models
{
[Serializable]
public class MCPConfigServer
public class McpConfigServer
{
[JsonProperty("command")]
public string command;

View File

@ -0,0 +1,12 @@
using System;
using Newtonsoft.Json;
namespace UnityMcpBridge.Editor.Models
{
[Serializable]
public class McpConfigServers
{
[JsonProperty("unityMCP")]
public McpConfigServer unityMCP;
}
}

View File

@ -1,4 +1,4 @@
namespace UnityMCP.Editor.Models
namespace UnityMcpBridge.Editor.Models
{
public class McpClient
{
@ -24,7 +24,7 @@ namespace UnityMCP.Editor.Models
McpStatus.UnsupportedOS => "Unsupported OS",
McpStatus.MissingConfig => "Missing UnityMCP Config",
McpStatus.Error => configStatus.StartsWith("Error:") ? configStatus : "Error",
_ => "Unknown"
_ => "Unknown",
};
}
@ -44,5 +44,3 @@ namespace UnityMCP.Editor.Models
}
}
}

View File

@ -1,12 +1,12 @@
using System;
using Newtonsoft.Json;
namespace UnityMCP.Editor.Models
namespace UnityMcpBridge.Editor.Models
{
[Serializable]
public class MCPConfig
public class McpConfig
{
[JsonProperty("mcpServers")]
public MCPConfigServers mcpServers;
public McpConfigServers mcpServers;
}
}

View File

@ -0,0 +1,18 @@
namespace UnityMcpBridge.Editor.Models
{
// Enum representing the various status states for MCP clients
public enum McpStatus
{
NotConfigured, // Not set up yet
Configured, // Successfully configured
Running, // Service is running
Connected, // Successfully connected
IncorrectPath, // Configuration has incorrect paths
CommunicationError, // Connected but communication issues
NoResponse, // Connected but not responding
MissingConfig, // Config file exists but missing required elements
UnsupportedOS, // OS is not supported
Error, // General error state
}
}

View File

@ -1,8 +1,9 @@
namespace UnityMCP.Editor.Models
namespace UnityMcpBridge.Editor.Models
{
public enum McpTypes
{
ClaudeDesktop,
Cursor
Cursor,
}
}

View File

@ -1,7 +1,7 @@
using System;
using Newtonsoft.Json;
namespace UnityMCP.Editor.Models
namespace UnityMcpBridge.Editor.Models
{
[Serializable]
public class ServerConfig

View File

@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
namespace UnityMCP.Editor.Tools
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Registry for all MCP command handlers (Refactored Version)
@ -19,7 +19,7 @@ namespace UnityMCP.Editor.Tools
{ "HandleManageGameObject", ManageGameObject.HandleCommand },
{ "HandleManageAsset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand },
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand },
};
/// <summary>
@ -43,3 +43,4 @@ namespace UnityMCP.Editor.Tools
}
}
}

View File

@ -1,11 +1,11 @@
using UnityEngine;
using UnityEditor;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic; // Added for HashSet
using UnityMCP.Editor.Helpers; // For Response class
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Helpers; // For Response class
namespace UnityMCP.Editor.Tools
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Handles executing Unity Editor menu items by path.
@ -14,7 +14,9 @@ namespace UnityMCP.Editor.Tools
{
// Basic blacklist to prevent accidental execution of potentially disruptive menu items.
// This can be expanded based on needs.
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
"File/Quit",
// Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed
@ -37,12 +39,19 @@ namespace UnityMCP.Editor.Tools
// Getting a comprehensive list of *all* menu items dynamically is very difficult
// and often requires complex reflection or maintaining a manual list.
// Returning a placeholder/acknowledgement for now.
Debug.LogWarning("[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex.");
Debug.LogWarning(
"[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex."
);
// Returning an empty list as per the refactor plan's requirements.
return Response.Success("'get_available_menus' action is not fully implemented. Returning empty list.", new List<string>());
return Response.Success(
"'get_available_menus' action is not fully implemented. Returning empty list.",
new List<string>()
);
// TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical.
default:
return Response.Error($"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'.");
return Response.Error(
$"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'."
);
}
}
catch (Exception e)
@ -69,7 +78,9 @@ namespace UnityMCP.Editor.Tools
// Validate against blacklist
if (_menuPathBlacklist.Contains(menuPath))
{
return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons.");
return Response.Error(
$"Execution of menu item '{menuPath}' is blocked for safety reasons."
);
}
// TODO: Implement alias lookup here if needed (Map alias to actual menuPath).
@ -82,26 +93,41 @@ namespace UnityMCP.Editor.Tools
try
{
// Attempt to execute the menu item on the main thread using delayCall for safety.
EditorApplication.delayCall += () => {
try {
EditorApplication.delayCall += () =>
{
try
{
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
// Log potential failure inside the delayed call.
if (!executed) {
Debug.LogError($"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent.");
if (!executed)
{
Debug.LogError(
$"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."
);
}
} catch (Exception delayEx) {
Debug.LogError($"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}");
}
catch (Exception delayEx)
{
Debug.LogError(
$"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}"
);
}
};
// Report attempt immediately, as execution is delayed.
return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
return Response.Success(
$"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."
);
}
catch (Exception e)
{
// Catch errors during setup phase.
Debug.LogError($"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}");
return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}");
Debug.LogError(
$"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}"
);
return Response.Error(
$"Error setting up execution for menu item '{menuPath}': {e.Message}"
);
}
}
@ -109,3 +135,4 @@ namespace UnityMCP.Editor.Tools
// private static string LookupAlias(string alias) { ... return actualMenuPath or null ... }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,13 @@
using UnityEngine;
using UnityEditor;
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
using System.Collections.Generic;
using UnityMCP.Editor.Helpers; // For Response class
using UnityEditor.ShortcutManagement;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal; // Required for tag management
using System.Reflection; // Required for layer management
using UnityEngine;
using UnityMcpBridge.Editor.Helpers; // For Response class
namespace UnityMCP.Editor.Tools
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Handles operations related to controlling and querying the Unity Editor state,
@ -19,6 +17,7 @@ namespace UnityMCP.Editor.Tools
{
// Constant for starting user layer index
private const int FirstUserLayerIndex = 8;
// Constant for total layer count
private const int TotalLayerCount = 32;
@ -62,7 +61,9 @@ namespace UnityMCP.Editor.Tools
if (EditorApplication.isPlaying)
{
EditorApplication.isPaused = !EditorApplication.isPaused;
return Response.Success(EditorApplication.isPaused ? "Game paused." : "Game resumed.");
return Response.Success(
EditorApplication.isPaused ? "Game paused." : "Game resumed."
);
}
return Response.Error("Cannot pause/resume: Not in play mode.");
}
@ -96,25 +97,30 @@ namespace UnityMCP.Editor.Tools
return GetSelection();
case "set_active_tool":
string toolName = @params["toolName"]?.ToString();
if (string.IsNullOrEmpty(toolName)) return Response.Error("'toolName' parameter required for set_active_tool.");
if (string.IsNullOrEmpty(toolName))
return Response.Error("'toolName' parameter required for set_active_tool.");
return SetActiveTool(toolName);
// Tag Management
case "add_tag":
if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for add_tag.");
if (string.IsNullOrEmpty(tagName))
return Response.Error("'tagName' parameter required for add_tag.");
return AddTag(tagName);
case "remove_tag":
if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for remove_tag.");
if (string.IsNullOrEmpty(tagName))
return Response.Error("'tagName' parameter required for remove_tag.");
return RemoveTag(tagName);
case "get_tags":
return GetTags(); // Helper to list current tags
// Layer Management
case "add_layer":
if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for add_layer.");
if (string.IsNullOrEmpty(layerName))
return Response.Error("'layerName' parameter required for add_layer.");
return AddLayer(layerName);
case "remove_layer":
if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for remove_layer.");
if (string.IsNullOrEmpty(layerName))
return Response.Error("'layerName' parameter required for remove_layer.");
return RemoveLayer(layerName);
case "get_layers":
return GetLayers(); // Helper to list current layers
@ -130,7 +136,9 @@ namespace UnityMCP.Editor.Tools
// return SetQualityLevel(@params["qualityLevel"]);
default:
return Response.Error($"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers.");
return Response.Error(
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
);
}
}
@ -147,7 +155,7 @@ namespace UnityMCP.Editor.Tools
isUpdating = EditorApplication.isUpdating,
applicationPath = EditorApplication.applicationPath,
applicationContentsPath = EditorApplication.applicationContentsPath,
timeSinceStartup = EditorApplication.timeSinceStartup
timeSinceStartup = EditorApplication.timeSinceStartup,
};
return Response.Success("Retrieved editor state.", state);
}
@ -162,7 +170,8 @@ namespace UnityMCP.Editor.Tools
try
{
// Get all types deriving from EditorWindow
var windowTypes = AppDomain.CurrentDomain.GetAssemblies()
var windowTypes = AppDomain
.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => type.IsSubclassOf(typeof(EditorWindow)))
.ToList();
@ -175,22 +184,33 @@ namespace UnityMCP.Editor.Tools
foreach (EditorWindow window in allWindows)
{
if (window == null) continue; // Skip potentially destroyed windows
if (window == null)
continue; // Skip potentially destroyed windows
try
{
openWindows.Add(new
openWindows.Add(
new
{
title = window.titleContent.text,
typeName = window.GetType().FullName,
isFocused = EditorWindow.focusedWindow == window,
position = new { x = window.position.x, y = window.position.y, width = window.position.width, height = window.position.height },
instanceID = window.GetInstanceID()
});
position = new
{
x = window.position.x,
y = window.position.y,
width = window.position.width,
height = window.position.height,
},
instanceID = window.GetInstanceID(),
}
);
}
catch (Exception ex)
{
Debug.LogWarning($"Could not get info for window {window.GetType().Name}: {ex.Message}");
Debug.LogWarning(
$"Could not get info for window {window.GetType().Name}: {ex.Message}"
);
}
}
@ -209,15 +229,18 @@ namespace UnityMCP.Editor.Tools
Tool currentTool = UnityEditor.Tools.current;
string toolName = currentTool.ToString(); // Enum to string
bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active
string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; // Get custom name if needed
string activeToolName = customToolActive
? EditorTools.GetActiveToolName()
: toolName; // Get custom name if needed
var toolInfo = new {
var toolInfo = new
{
activeTool = activeToolName,
isCustom = customToolActive,
pivotMode = UnityEditor.Tools.pivotMode.ToString(),
pivotRotation = UnityEditor.Tools.pivotRotation.ToString(),
handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity
handlePosition = UnityEditor.Tools.handlePosition
handlePosition = UnityEditor.Tools.handlePosition,
};
return Response.Success("Retrieved active tool information.", toolInfo);
@ -243,14 +266,18 @@ namespace UnityMCP.Editor.Tools
}
else
{
return Response.Error($"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid.");
return Response.Error(
$"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid."
);
}
}
else
{
// Potentially try activating a custom tool by name here if needed
// This often requires specific editor scripting knowledge for that tool.
return Response.Error($"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom).");
return Response.Error(
$"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)."
);
}
}
catch (Exception e)
@ -270,9 +297,22 @@ namespace UnityMCP.Editor.Tools
activeTransform = Selection.activeTransform?.name,
activeInstanceID = Selection.activeInstanceID,
count = Selection.count,
objects = Selection.objects.Select(obj => new { name = obj?.name, type = obj?.GetType().FullName, instanceID = obj?.GetInstanceID() }).ToList(),
gameObjects = Selection.gameObjects.Select(go => new { name = go?.name, instanceID = go?.GetInstanceID() }).ToList(),
assetGUIDs = Selection.assetGUIDs // GUIDs for selected assets in Project view
objects = Selection
.objects.Select(obj => new
{
name = obj?.name,
type = obj?.GetType().FullName,
instanceID = obj?.GetInstanceID(),
})
.ToList(),
gameObjects = Selection
.gameObjects.Select(go => new
{
name = go?.name,
instanceID = go?.GetInstanceID(),
})
.ToList(),
assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view
};
return Response.Success("Retrieved current selection details.", selectionInfo);
@ -351,7 +391,6 @@ namespace UnityMCP.Editor.Tools
}
}
// --- Layer Management Methods ---
private static object AddLayer(string layerName)
@ -361,7 +400,8 @@ namespace UnityMCP.Editor.Tools
// Access the TagManager asset
SerializedObject tagManager = GetTagManager();
if (tagManager == null) return Response.Error("Could not access TagManager asset.");
if (tagManager == null)
return Response.Error("Could not access TagManager asset.");
SerializedProperty layersProp = tagManager.FindProperty("layers");
if (layersProp == null || !layersProp.isArray)
@ -371,7 +411,10 @@ namespace UnityMCP.Editor.Tools
for (int i = 0; i < TotalLayerCount; i++)
{
SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
if (layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase))
if (
layerSP != null
&& layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
)
{
return Response.Error($"Layer '{layerName}' already exists at index {i}.");
}
@ -397,13 +440,17 @@ namespace UnityMCP.Editor.Tools
// Assign the name to the found slot
try
{
SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(firstEmptyUserLayer);
SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
firstEmptyUserLayer
);
targetLayerSP.stringValue = layerName;
// Apply the changes to the TagManager asset
tagManager.ApplyModifiedProperties();
// Save assets to make sure it's written to disk
AssetDatabase.SaveAssets();
return Response.Success($"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}.");
return Response.Success(
$"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}."
);
}
catch (Exception e)
{
@ -418,7 +465,8 @@ namespace UnityMCP.Editor.Tools
// Access the TagManager asset
SerializedObject tagManager = GetTagManager();
if (tagManager == null) return Response.Error("Could not access TagManager asset.");
if (tagManager == null)
return Response.Error("Could not access TagManager asset.");
SerializedProperty layersProp = tagManager.FindProperty("layers");
if (layersProp == null || !layersProp.isArray)
@ -430,7 +478,10 @@ namespace UnityMCP.Editor.Tools
{
SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
// Case-insensitive comparison is safer
if (layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase))
if (
layerSP != null
&& layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
)
{
layerIndexToRemove = i;
break;
@ -445,13 +496,17 @@ namespace UnityMCP.Editor.Tools
// Clear the name for that index
try
{
SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(layerIndexToRemove);
SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
layerIndexToRemove
);
targetLayerSP.stringValue = string.Empty; // Set to empty string to remove
// Apply the changes
tagManager.ApplyModifiedProperties();
// Save assets
AssetDatabase.SaveAssets();
return Response.Success($"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully.");
return Response.Success(
$"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully."
);
}
catch (Exception e)
{
@ -480,7 +535,6 @@ namespace UnityMCP.Editor.Tools
}
}
// --- Helper Methods ---
/// <summary>
@ -491,7 +545,9 @@ namespace UnityMCP.Editor.Tools
try
{
// Load the TagManager asset from the ProjectSettings folder
UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/TagManager.asset");
UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath(
"ProjectSettings/TagManager.asset"
);
if (tagManagerAssets == null || tagManagerAssets.Length == 0)
{
Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings.");
@ -515,8 +571,10 @@ namespace UnityMCP.Editor.Tools
}
// Helper class to get custom tool names (remains the same)
internal static class EditorTools {
public static string GetActiveToolName() {
internal static class EditorTools
{
public static string GetActiveToolName()
{
// This is a placeholder. Real implementation depends on how custom tools
// are registered and tracked in the specific Unity project setup.
// It might involve checking static variables, calling methods on specific tool managers, etc.
@ -530,3 +588,4 @@ namespace UnityMCP.Editor.Tools
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor;
using UnityEditor.SceneManagement;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using UnityMCP.Editor.Helpers; // For Response class
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityMcpBridge.Editor.Helpers; // For Response class
namespace UnityMCP.Editor.Tools
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Handles scene management operations like loading, saving, creating, and querying hierarchy.
@ -29,7 +29,8 @@ namespace UnityMCP.Editor.Tools
// Ensure path is relative to Assets/, removing any leading "Assets/"
string relativeDir = path ?? string.Empty;
if (!string.IsNullOrEmpty(relativeDir)) {
if (!string.IsNullOrEmpty(relativeDir))
{
relativeDir = relativeDir.Replace('\\', '/').Trim('/');
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
@ -51,9 +52,13 @@ namespace UnityMCP.Editor.Tools
string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity";
// Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName
string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets)
string fullPath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine(fullPathDir, sceneFileName);
string fullPath = string.IsNullOrEmpty(sceneFileName)
? null
: Path.Combine(fullPathDir, sceneFileName);
// Ensure relativePath always starts with "Assets/" and uses forward slashes
string relativePath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/');
string relativePath = string.IsNullOrEmpty(sceneFileName)
? null
: Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/');
// Ensure directory exists for 'create'
if (action == "create" && !string.IsNullOrEmpty(fullPathDir))
@ -64,7 +69,9 @@ namespace UnityMCP.Editor.Tools
}
catch (Exception e)
{
return Response.Error($"Could not create directory '{fullPathDir}': {e.Message}");
return Response.Error(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
@ -73,7 +80,9 @@ namespace UnityMCP.Editor.Tools
{
case "create":
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath))
return Response.Error("'name' and 'path' parameters are required for 'create' action.");
return Response.Error(
"'name' and 'path' parameters are required for 'create' action."
);
return CreateScene(fullPath, relativePath);
case "load":
// Loading can be done by path/name or build index
@ -82,7 +91,9 @@ namespace UnityMCP.Editor.Tools
else if (buildIndex.HasValue)
return LoadScene(buildIndex.Value);
else
return Response.Error("Either 'name'/'path' or 'buildIndex' must be provided for 'load' action.");
return Response.Error(
"Either 'name'/'path' or 'buildIndex' must be provided for 'load' action."
);
case "save":
// Save current scene, optionally to a new path
return SaveScene(fullPath, relativePath);
@ -94,7 +105,9 @@ namespace UnityMCP.Editor.Tools
return GetBuildSettingsScenes();
// Add cases for modifying build settings, additive loading, unloading etc.
default:
return Response.Error($"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings.");
return Response.Error(
$"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings."
);
}
}
@ -108,14 +121,20 @@ namespace UnityMCP.Editor.Tools
try
{
// Create a new empty scene
Scene newScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
Scene newScene = EditorSceneManager.NewScene(
NewSceneSetup.EmptyScene,
NewSceneMode.Single
);
// Save it to the specified path
bool saved = EditorSceneManager.SaveScene(newScene, relativePath);
if (saved)
{
AssetDatabase.Refresh(); // Ensure Unity sees the new scene file
return Response.Success($"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", new { path = relativePath });
return Response.Success(
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
else
{
@ -132,7 +151,17 @@ namespace UnityMCP.Editor.Tools
private static object LoadScene(string relativePath)
{
if (!File.Exists(Path.Combine(Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length), relativePath)))
if (
!File.Exists(
Path.Combine(
Application.dataPath.Substring(
0,
Application.dataPath.Length - "Assets".Length
),
relativePath
)
)
)
{
return Response.Error($"Scene file not found at '{relativePath}'.");
}
@ -141,7 +170,9 @@ namespace UnityMCP.Editor.Tools
if (EditorSceneManager.GetActiveScene().isDirty)
{
// Optionally prompt the user or save automatically before loading
return Response.Error("Current scene has unsaved changes. Please save or discard changes before loading a new scene.");
return Response.Error(
"Current scene has unsaved changes. Please save or discard changes before loading a new scene."
);
// Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
// if (!saveOK) return Response.Error("Load cancelled by user.");
}
@ -149,7 +180,14 @@ namespace UnityMCP.Editor.Tools
try
{
EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single);
return Response.Success($"Scene '{relativePath}' loaded successfully.", new { path = relativePath, name = Path.GetFileNameWithoutExtension(relativePath) });
return Response.Success(
$"Scene '{relativePath}' loaded successfully.",
new
{
path = relativePath,
name = Path.GetFileNameWithoutExtension(relativePath),
}
);
}
catch (Exception e)
{
@ -161,24 +199,38 @@ namespace UnityMCP.Editor.Tools
{
if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings)
{
return Response.Error($"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}.");
return Response.Error(
$"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}."
);
}
// Check for unsaved changes
if (EditorSceneManager.GetActiveScene().isDirty)
{
return Response.Error("Current scene has unsaved changes. Please save or discard changes before loading a new scene.");
return Response.Error(
"Current scene has unsaved changes. Please save or discard changes before loading a new scene."
);
}
try
{
string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex);
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
return Response.Success($"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", new { path = scenePath, name = Path.GetFileNameWithoutExtension(scenePath), buildIndex = buildIndex });
return Response.Success(
$"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.",
new
{
path = scenePath,
name = Path.GetFileNameWithoutExtension(scenePath),
buildIndex = buildIndex,
}
);
}
catch (Exception e)
{
return Response.Error($"Error loading scene with build index {buildIndex}: {e.Message}");
return Response.Error(
$"Error loading scene with build index {buildIndex}: {e.Message}"
);
}
}
@ -200,7 +252,8 @@ namespace UnityMCP.Editor.Tools
// Save As...
// Ensure directory exists
string dir = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
saved = EditorSceneManager.SaveScene(currentScene, relativePath);
finalPath = relativePath;
@ -211,7 +264,9 @@ namespace UnityMCP.Editor.Tools
if (string.IsNullOrEmpty(currentScene.path))
{
// Scene is untitled, needs a path
return Response.Error("Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality.");
return Response.Error(
"Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality."
);
}
saved = EditorSceneManager.SaveScene(currentScene);
}
@ -219,7 +274,10 @@ namespace UnityMCP.Editor.Tools
if (saved)
{
AssetDatabase.Refresh();
return Response.Success($"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name });
return Response.Success(
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
new { path = finalPath, name = currentScene.name }
);
}
else
{
@ -249,7 +307,7 @@ namespace UnityMCP.Editor.Tools
buildIndex = activeScene.buildIndex, // -1 if not in build settings
isDirty = activeScene.isDirty,
isLoaded = activeScene.isLoaded,
rootCount = activeScene.rootCount
rootCount = activeScene.rootCount,
};
return Response.Success("Retrieved active scene information.", sceneInfo);
@ -268,12 +326,15 @@ namespace UnityMCP.Editor.Tools
for (int i = 0; i < EditorBuildSettings.scenes.Length; i++)
{
var scene = EditorBuildSettings.scenes[i];
scenes.Add(new {
scenes.Add(
new
{
path = scene.path,
guid = scene.guid.ToString(),
enabled = scene.enabled,
buildIndex = i // Actual build index considering only enabled scenes might differ
});
buildIndex = i, // Actual build index considering only enabled scenes might differ
}
);
}
return Response.Success("Retrieved scenes from Build Settings.", scenes);
}
@ -290,13 +351,18 @@ namespace UnityMCP.Editor.Tools
Scene activeScene = EditorSceneManager.GetActiveScene();
if (!activeScene.IsValid() || !activeScene.isLoaded)
{
return Response.Error("No valid and loaded scene is active to get hierarchy from.");
return Response.Error(
"No valid and loaded scene is active to get hierarchy from."
);
}
GameObject[] rootObjects = activeScene.GetRootGameObjects();
var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList();
return Response.Success($"Retrieved hierarchy for scene '{activeScene.name}'.", hierarchy);
return Response.Success(
$"Retrieved hierarchy for scene '{activeScene.name}'.",
hierarchy
);
}
catch (Exception e)
{
@ -309,7 +375,8 @@ namespace UnityMCP.Editor.Tools
/// </summary>
private static object GetGameObjectDataRecursive(GameObject go)
{
if (go == null) return null;
if (go == null)
return null;
var childrenData = new List<object>();
foreach (Transform child in go.transform)
@ -326,16 +393,35 @@ namespace UnityMCP.Editor.Tools
{ "layer", go.layer },
{ "isStatic", go.isStatic },
{ "instanceID", go.GetInstanceID() }, // Useful unique identifier
{ "transform", new {
position = new { x = go.transform.localPosition.x, y = go.transform.localPosition.y, z = go.transform.localPosition.z },
rotation = new { x = go.transform.localRotation.eulerAngles.x, y = go.transform.localRotation.eulerAngles.y, z = go.transform.localRotation.eulerAngles.z }, // Euler for simplicity
scale = new { x = go.transform.localScale.x, y = go.transform.localScale.y, z = go.transform.localScale.z }
{
"transform",
new
{
position = new
{
x = go.transform.localPosition.x,
y = go.transform.localPosition.y,
z = go.transform.localPosition.z,
},
rotation = new
{
x = go.transform.localRotation.eulerAngles.x,
y = go.transform.localRotation.eulerAngles.y,
z = go.transform.localRotation.eulerAngles.z,
}, // Euler for simplicity
scale = new
{
x = go.transform.localScale.x,
y = go.transform.localScale.y,
z = go.transform.localScale.z,
},
}
},
{ "children", childrenData }
{ "children", childrenData },
};
return gameObjectData;
}
}
}

View File

@ -1,13 +1,13 @@
using UnityEngine;
using UnityEditor;
using Newtonsoft.Json.Linq;
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Linq;
using UnityMCP.Editor.Helpers;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Helpers;
namespace UnityMCP.Editor.Tools
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Handles CRUD operations for C# scripts within the Unity project.
@ -58,7 +58,9 @@ namespace UnityMCP.Editor.Tools
// Basic name validation (alphanumeric, underscores, cannot start with number)
if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
return Response.Error($"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number.");
return Response.Error(
$"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number."
);
}
// Ensure path is relative to Assets/, removing any leading "Assets/"
@ -73,7 +75,8 @@ namespace UnityMCP.Editor.Tools
}
}
// Handle empty string case explicitly after processing
if (string.IsNullOrEmpty(relativeDir)) {
if (string.IsNullOrEmpty(relativeDir))
{
relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/"
}
@ -81,7 +84,8 @@ namespace UnityMCP.Editor.Tools
string scriptFileName = $"{name}.cs";
string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Application.dataPath ends in "Assets"
string fullPath = Path.Combine(fullPathDir, scriptFileName);
string relativePath = Path.Combine("Assets", relativeDir, scriptFileName).Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes
string relativePath = Path.Combine("Assets", relativeDir, scriptFileName)
.Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes
// Ensure the target directory exists for create/update
if (action == "create" || action == "update")
@ -92,7 +96,9 @@ namespace UnityMCP.Editor.Tools
}
catch (Exception e)
{
return Response.Error($"Could not create directory '{fullPathDir}': {e.Message}");
return Response.Error(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
@ -100,7 +106,14 @@ namespace UnityMCP.Editor.Tools
switch (action)
{
case "create":
return CreateScript(fullPath, relativePath, name, contents, scriptType, namespaceName);
return CreateScript(
fullPath,
relativePath,
name,
contents,
scriptType,
namespaceName
);
case "read":
return ReadScript(fullPath, relativePath);
case "update":
@ -108,7 +121,9 @@ namespace UnityMCP.Editor.Tools
case "delete":
return DeleteScript(fullPath, relativePath);
default:
return Response.Error($"Unknown action: '{action}'. Valid actions are: create, read, update, delete.");
return Response.Error(
$"Unknown action: '{action}'. Valid actions are: create, read, update, delete."
);
}
}
@ -130,12 +145,21 @@ namespace UnityMCP.Editor.Tools
return Convert.ToBase64String(data);
}
private static object CreateScript(string fullPath, string relativePath, string name, string contents, string scriptType, string namespaceName)
private static object CreateScript(
string fullPath,
string relativePath,
string name,
string contents,
string scriptType,
string namespaceName
)
{
// Check if script already exists
if (File.Exists(fullPath))
{
return Response.Error($"Script already exists at '{relativePath}'. Use 'update' action to modify.");
return Response.Error(
$"Script already exists at '{relativePath}'. Use 'update' action to modify."
);
}
// Generate default content if none provided
@ -157,7 +181,10 @@ namespace UnityMCP.Editor.Tools
File.WriteAllText(fullPath, contents);
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(); // Ensure Unity recognizes the new script
return Response.Success($"Script '{name}.cs' created successfully at '{relativePath}'.", new { path = relativePath });
return Response.Success(
$"Script '{name}.cs' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
catch (Exception e)
{
@ -178,15 +205,19 @@ namespace UnityMCP.Editor.Tools
// Return both normal and encoded contents for larger files
bool isLarge = contents.Length > 10000; // If content is large, include encoded version
var responseData = new {
var responseData = new
{
path = relativePath,
contents = contents,
// For large files, also include base64-encoded version
encodedContents = isLarge ? EncodeBase64(contents) : null,
contentsEncoded = isLarge
contentsEncoded = isLarge,
};
return Response.Success($"Script '{Path.GetFileName(relativePath)}' read successfully.", responseData);
return Response.Success(
$"Script '{Path.GetFileName(relativePath)}' read successfully.",
responseData
);
}
catch (Exception e)
{
@ -194,11 +225,18 @@ namespace UnityMCP.Editor.Tools
}
}
private static object UpdateScript(string fullPath, string relativePath, string name, string contents)
private static object UpdateScript(
string fullPath,
string relativePath,
string name,
string contents
)
{
if (!File.Exists(fullPath))
{
return Response.Error($"Script not found at '{relativePath}'. Use 'create' action to add a new script.");
return Response.Error(
$"Script not found at '{relativePath}'. Use 'create' action to add a new script."
);
}
if (string.IsNullOrEmpty(contents))
{
@ -217,7 +255,10 @@ namespace UnityMCP.Editor.Tools
File.WriteAllText(fullPath, contents);
AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes
AssetDatabase.Refresh();
return Response.Success($"Script '{name}.cs' updated successfully at '{relativePath}'.", new { path = relativePath });
return Response.Success(
$"Script '{name}.cs' updated successfully at '{relativePath}'.",
new { path = relativePath }
);
}
catch (Exception e)
{
@ -239,12 +280,16 @@ namespace UnityMCP.Editor.Tools
if (deleted)
{
AssetDatabase.Refresh();
return Response.Success($"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.");
return Response.Success(
$"Script '{Path.GetFileName(relativePath)}' moved to trash successfully."
);
}
else
{
// Fallback or error if MoveAssetToTrash fails
return Response.Error($"Failed to move script '{relativePath}' to trash. It might be locked or in use.");
return Response.Error(
$"Failed to move script '{relativePath}' to trash. It might be locked or in use."
);
}
}
catch (Exception e)
@ -256,11 +301,16 @@ namespace UnityMCP.Editor.Tools
/// <summary>
/// Generates basic C# script content based on name and type.
/// </summary>
private static string GenerateDefaultScriptContent(string name, string scriptType, string namespaceName)
private static string GenerateDefaultScriptContent(
string name,
string scriptType,
string namespaceName
)
{
string usingStatements = "using UnityEngine;\nusing System.Collections;\n";
string classDeclaration;
string body = "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n";
string body =
"\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n";
string baseClass = "";
if (!string.IsNullOrEmpty(scriptType))
@ -272,7 +322,10 @@ namespace UnityMCP.Editor.Tools
baseClass = " : ScriptableObject";
body = ""; // ScriptableObjects don't usually need Start/Update
}
else if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase))
else if (
scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)
|| scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase)
)
{
usingStatements += "using UnityEditor;\n";
if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase))
@ -313,13 +366,16 @@ namespace UnityMCP.Editor.Tools
/// </summary>
private static bool ValidateScriptSyntax(string contents)
{
if (string.IsNullOrEmpty(contents)) return true; // Empty is technically valid?
if (string.IsNullOrEmpty(contents))
return true; // Empty is technically valid?
int braceBalance = 0;
foreach (char c in contents)
{
if (c == '{') braceBalance++;
else if (c == '}') braceBalance--;
if (c == '{')
braceBalance++;
else if (c == '}')
braceBalance--;
}
return braceBalance == 0;
@ -328,3 +384,4 @@ namespace UnityMCP.Editor.Tools
}
}
}

View File

@ -0,0 +1,516 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
using UnityMcpBridge.Editor.Helpers; // For Response class
namespace UnityMcpBridge.Editor.Tools
{
/// <summary>
/// Handles reading and clearing Unity Editor console log entries.
/// Uses reflection to access internal LogEntry methods/properties.
/// </summary>
public static class ReadConsole
{
// Reflection members for accessing internal LogEntry data
// private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection
private static MethodInfo _startGettingEntriesMethod;
private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End...
private static MethodInfo _clearMethod;
private static MethodInfo _getCountMethod;
private static MethodInfo _getEntryMethod;
private static FieldInfo _modeField;
private static FieldInfo _messageField;
private static FieldInfo _fileField;
private static FieldInfo _lineField;
private static FieldInfo _instanceIdField;
// Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative?
// Static constructor for reflection setup
static ReadConsole()
{
try
{
Type logEntriesType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntries"
);
if (logEntriesType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntries");
// Include NonPublic binding flags as internal APIs might change accessibility
BindingFlags staticFlags =
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
BindingFlags instanceFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
_startGettingEntriesMethod = logEntriesType.GetMethod(
"StartGettingEntries",
staticFlags
);
if (_startGettingEntriesMethod == null)
throw new Exception("Failed to reflect LogEntries.StartGettingEntries");
// Try reflecting EndGettingEntries based on warning message
_endGettingEntriesMethod = logEntriesType.GetMethod(
"EndGettingEntries",
staticFlags
);
if (_endGettingEntriesMethod == null)
throw new Exception("Failed to reflect LogEntries.EndGettingEntries");
_clearMethod = logEntriesType.GetMethod("Clear", staticFlags);
if (_clearMethod == null)
throw new Exception("Failed to reflect LogEntries.Clear");
_getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags);
if (_getCountMethod == null)
throw new Exception("Failed to reflect LogEntries.GetCount");
_getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags);
if (_getEntryMethod == null)
throw new Exception("Failed to reflect LogEntries.GetEntryInternal");
Type logEntryType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntry"
);
if (logEntryType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntry");
_modeField = logEntryType.GetField("mode", instanceFlags);
if (_modeField == null)
throw new Exception("Failed to reflect LogEntry.mode");
_messageField = logEntryType.GetField("message", instanceFlags);
if (_messageField == null)
throw new Exception("Failed to reflect LogEntry.message");
_fileField = logEntryType.GetField("file", instanceFlags);
if (_fileField == null)
throw new Exception("Failed to reflect LogEntry.file");
_lineField = logEntryType.GetField("line", instanceFlags);
if (_lineField == null)
throw new Exception("Failed to reflect LogEntry.line");
_instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
if (_instanceIdField == null)
throw new Exception("Failed to reflect LogEntry.instanceID");
}
catch (Exception e)
{
Debug.LogError(
$"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}"
);
// Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this.
_startGettingEntriesMethod =
_endGettingEntriesMethod =
_clearMethod =
_getCountMethod =
_getEntryMethod =
null;
_modeField = _messageField = _fileField = _lineField = _instanceIdField = null;
}
}
// --- Main Handler ---
public static object HandleCommand(JObject @params)
{
// Check if ALL required reflection members were successfully initialized.
if (
_startGettingEntriesMethod == null
|| _endGettingEntriesMethod == null
|| _clearMethod == null
|| _getCountMethod == null
|| _getEntryMethod == null
|| _modeField == null
|| _messageField == null
|| _fileField == null
|| _lineField == null
|| _instanceIdField == null
)
{
// Log the error here as well for easier debugging in Unity Console
Debug.LogError(
"[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue."
);
return Response.Error(
"ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs."
);
}
string action = @params["action"]?.ToString().ToLower() ?? "get";
try
{
if (action == "clear")
{
return ClearConsole();
}
else if (action == "get")
{
// Extract parameters for 'get'
var types =
(@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList()
?? new List<string> { "error", "warning", "log" };
int? count = @params["count"]?.ToObject<int?>();
string filterText = @params["filterText"]?.ToString();
string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering
string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
bool includeStacktrace =
@params["includeStacktrace"]?.ToObject<bool?>() ?? true;
if (types.Contains("all"))
{
types = new List<string> { "error", "warning", "log" }; // Expand 'all'
}
if (!string.IsNullOrEmpty(sinceTimestampStr))
{
Debug.LogWarning(
"[ReadConsole] Filtering by 'since_timestamp' is not currently implemented."
);
// Need a way to get timestamp per log entry.
}
return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
}
else
{
return Response.Error(
$"Unknown action: '{action}'. Valid actions are 'get' or 'clear'."
);
}
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}");
return Response.Error($"Internal error processing action '{action}': {e.Message}");
}
}
// --- Action Implementations ---
private static object ClearConsole()
{
try
{
_clearMethod.Invoke(null, null); // Static method, no instance, no parameters
return Response.Success("Console cleared successfully.");
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Failed to clear console: {e}");
return Response.Error($"Failed to clear console: {e.Message}");
}
}
private static object GetConsoleEntries(
List<string> types,
int? count,
string filterText,
string format,
bool includeStacktrace
)
{
List<object> formattedEntries = new List<object>();
int retrievedCount = 0;
try
{
// LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal
_startGettingEntriesMethod.Invoke(null, null);
int totalEntries = (int)_getCountMethod.Invoke(null, null);
// Create instance to pass to GetEntryInternal - Ensure the type is correct
Type logEntryType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntry"
);
if (logEntryType == null)
throw new Exception(
"Could not find internal type UnityEditor.LogEntry during GetConsoleEntries."
);
object logEntryInstance = Activator.CreateInstance(logEntryType);
for (int i = 0; i < totalEntries; i++)
{
// Get the entry data into our instance using reflection
_getEntryMethod.Invoke(null, new object[] { i, logEntryInstance });
// Extract data using reflection
int mode = (int)_modeField.GetValue(logEntryInstance);
string message = (string)_messageField.GetValue(logEntryInstance);
string file = (string)_fileField.GetValue(logEntryInstance);
int line = (int)_lineField.GetValue(logEntryInstance);
// int instanceId = (int)_instanceIdField.GetValue(logEntryInstance);
if (string.IsNullOrEmpty(message))
continue; // Skip empty messages
// --- Filtering ---
// Filter by type
LogType currentType = GetLogTypeFromMode(mode);
if (!types.Contains(currentType.ToString().ToLowerInvariant()))
{
continue;
}
// Filter by text (case-insensitive)
if (
!string.IsNullOrEmpty(filterText)
&& message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0
)
{
continue;
}
// TODO: Filter by timestamp (requires timestamp data)
// --- Formatting ---
string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null;
// Get first line if stack is present and requested, otherwise use full message
string messageOnly =
(includeStacktrace && !string.IsNullOrEmpty(stackTrace))
? message.Split(
new[] { '\n', '\r' },
StringSplitOptions.RemoveEmptyEntries
)[0]
: message;
object formattedEntry = null;
switch (format)
{
case "plain":
formattedEntry = messageOnly;
break;
case "json":
case "detailed": // Treat detailed as json for structured return
default:
formattedEntry = new
{
type = currentType.ToString(),
message = messageOnly,
file = file,
line = line,
// timestamp = "", // TODO
stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found
};
break;
}
formattedEntries.Add(formattedEntry);
retrievedCount++;
// Apply count limit (after filtering)
if (count.HasValue && retrievedCount >= count.Value)
{
break;
}
}
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
// Ensure EndGettingEntries is called even if there's an error during iteration
try
{
_endGettingEntriesMethod.Invoke(null, null);
}
catch
{ /* Ignore nested exception */
}
return Response.Error($"Error retrieving log entries: {e.Message}");
}
finally
{
// Ensure we always call EndGettingEntries
try
{
_endGettingEntriesMethod.Invoke(null, null);
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}");
// Don't return error here as we might have valid data, but log it.
}
}
// Return the filtered and formatted list (might be empty)
return Response.Success(
$"Retrieved {formattedEntries.Count} log entries.",
formattedEntries
);
}
// --- Internal Helpers ---
// Mapping from LogEntry.mode bits to LogType enum
// Based on decompiled UnityEditor code or common patterns. Precise bits might change between Unity versions.
// See comments below for LogEntry mode bits exploration.
// Note: This mapping is simplified and might not cover all edge cases or future Unity versions perfectly.
private const int ModeBitError = 1 << 0;
private const int ModeBitAssert = 1 << 1;
private const int ModeBitWarning = 1 << 2;
private const int ModeBitLog = 1 << 3;
private const int ModeBitException = 1 << 4; // Often combined with Error bits
private const int ModeBitScriptingError = 1 << 9;
private const int ModeBitScriptingWarning = 1 << 10;
private const int ModeBitScriptingLog = 1 << 11;
private const int ModeBitScriptingException = 1 << 18;
private const int ModeBitScriptingAssertion = 1 << 22;
private static LogType GetLogTypeFromMode(int mode)
{
// First, determine the type based on the original logic (most severe first)
LogType initialType;
if (
(
mode
& (
ModeBitError
| ModeBitScriptingError
| ModeBitException
| ModeBitScriptingException
)
) != 0
)
{
initialType = LogType.Error;
}
else if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0)
{
initialType = LogType.Assert;
}
else if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0)
{
initialType = LogType.Warning;
}
else
{
initialType = LogType.Log;
}
// Apply the observed "one level lower" correction
switch (initialType)
{
case LogType.Error:
return LogType.Warning; // Error becomes Warning
case LogType.Warning:
return LogType.Log; // Warning becomes Log
case LogType.Assert:
return LogType.Assert; // Assert remains Assert (no lower level defined)
case LogType.Log:
return LogType.Log; // Log remains Log
default:
return LogType.Log; // Default fallback
}
}
/// <summary>
/// Attempts to extract the stack trace part from a log message.
/// Unity log messages often have the stack trace appended after the main message,
/// starting on a new line and typically indented or beginning with "at ".
/// </summary>
/// <param name="fullMessage">The complete log message including potential stack trace.</param>
/// <returns>The extracted stack trace string, or null if none is found.</returns>
private static string ExtractStackTrace(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage))
return null;
// Split into lines, removing empty ones to handle different line endings gracefully.
// Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here.
string[] lines = fullMessage.Split(
new[] { '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries
);
// If there's only one line or less, there's no separate stack trace.
if (lines.Length <= 1)
return null;
int stackStartIndex = -1;
// Start checking from the second line onwards.
for (int i = 1; i < lines.Length; ++i)
{
// Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical.
string trimmedLine = lines[i].TrimStart();
// Check for common stack trace patterns.
if (
trimmedLine.StartsWith("at ")
|| trimmedLine.StartsWith("UnityEngine.")
|| trimmedLine.StartsWith("UnityEditor.")
|| trimmedLine.Contains("(at ")
|| // Covers "(at Assets/..." pattern
// Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
(
trimmedLine.Length > 0
&& char.IsUpper(trimmedLine[0])
&& trimmedLine.Contains('.')
)
)
{
stackStartIndex = i;
break; // Found the likely start of the stack trace
}
}
// If a potential start index was found...
if (stackStartIndex > 0)
{
// Join the lines from the stack start index onwards using standard newline characters.
// This reconstructs the stack trace part of the message.
return string.Join("\n", lines.Skip(stackStartIndex));
}
// No clear stack trace found based on the patterns.
return null;
}
/* LogEntry.mode bits exploration (based on Unity decompilation/observation):
May change between versions.
Basic Types:
kError = 1 << 0 (1)
kAssert = 1 << 1 (2)
kWarning = 1 << 2 (4)
kLog = 1 << 3 (8)
kFatal = 1 << 4 (16) - Often treated as Exception/Error
Modifiers/Context:
kAssetImportError = 1 << 7 (128)
kAssetImportWarning = 1 << 8 (256)
kScriptingError = 1 << 9 (512)
kScriptingWarning = 1 << 10 (1024)
kScriptingLog = 1 << 11 (2048)
kScriptCompileError = 1 << 12 (4096)
kScriptCompileWarning = 1 << 13 (8192)
kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play
kMayIgnoreLineNumber = 1 << 15 (32768)
kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button
kDisplayPreviousErrorInStatusBar = 1 << 17 (131072)
kScriptingException = 1 << 18 (262144)
kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI
kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior
kGraphCompileError = 1 << 21 (2097152)
kScriptingAssertion = 1 << 22 (4194304)
kVisualScriptingError = 1 << 23 (8388608)
Example observed values:
Log: 2048 (ScriptingLog) or 8 (Log)
Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning)
Error: 513 (ScriptingError | Error) or 1 (Error)
Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination
Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert)
*/
}
}

View File

@ -1,26 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.IO;
using UnityMCP.Editor.Models;
using UnityMCP.Editor.Tools;
using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Models;
using UnityMcpBridge.Editor.Tools;
namespace UnityMCP.Editor
namespace UnityMcpBridge.Editor
{
[InitializeOnLoad]
public static partial class UnityMCPBridge
public static partial class UnityMcpBridge
{
private static TcpListener listener;
private static bool isRunning = false;
private static readonly object lockObj = new();
private static Dictionary<string, (string commandJson, TaskCompletionSource<string> tcs)> commandQueue = new();
private static Dictionary<
string,
(string commandJson, TaskCompletionSource<string> tcs)
> commandQueue = new();
private static readonly int unityPort = 6400; // Hardcoded port
public static bool IsRunning => isRunning;
@ -33,11 +36,14 @@ namespace UnityMCP.Editor
if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase))
return true;
string fullPath = Path.Combine(Application.dataPath, path.StartsWith("Assets/") ? path.Substring(7) : path);
string fullPath = Path.Combine(
Application.dataPath,
path.StartsWith("Assets/") ? path.Substring(7) : path
);
return Directory.Exists(fullPath);
}
static UnityMCPBridge()
static UnityMcpBridge()
{
Start();
EditorApplication.quitting += Stop;
@ -45,7 +51,8 @@ namespace UnityMCP.Editor
public static void Start()
{
if (isRunning) return;
if (isRunning)
return;
isRunning = true;
listener = new TcpListener(IPAddress.Loopback, unityPort);
listener.Start();
@ -56,7 +63,8 @@ namespace UnityMCP.Editor
public static void Stop()
{
if (!isRunning) return;
if (!isRunning)
return;
isRunning = false;
listener.Stop();
EditorApplication.update -= ProcessCommands;
@ -71,7 +79,11 @@ namespace UnityMCP.Editor
{
var client = await listener.AcceptTcpClientAsync();
// Enable basic socket keepalive
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
client.Client.SetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.KeepAlive,
true
);
// Set longer receive timeout to prevent quick disconnections
client.ReceiveTimeout = 60000; // 60 seconds
@ -81,7 +93,8 @@ namespace UnityMCP.Editor
}
catch (Exception ex)
{
if (isRunning) Debug.LogError($"Listener error: {ex.Message}");
if (isRunning)
Debug.LogError($"Listener error: {ex.Message}");
}
}
}
@ -97,9 +110,14 @@ namespace UnityMCP.Editor
try
{
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) break; // Client disconnected
if (bytesRead == 0)
break; // Client disconnected
string commandText = System.Text.Encoding.UTF8.GetString(buffer, 0, bytesRead);
string commandText = System.Text.Encoding.UTF8.GetString(
buffer,
0,
bytesRead
);
string commandId = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<string>();
@ -107,7 +125,9 @@ namespace UnityMCP.Editor
if (commandText.Trim() == "ping")
{
// Direct response to ping without going through JSON parsing
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes("{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}");
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
);
await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length);
continue;
}
@ -149,7 +169,7 @@ namespace UnityMCP.Editor
var emptyResponse = new
{
status = "error",
error = "Empty command received"
error = "Empty command received",
};
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
processedIds.Add(id);
@ -165,7 +185,7 @@ namespace UnityMCP.Editor
var pingResponse = new
{
status = "success",
result = new { message = "pong" }
result = new { message = "pong" },
};
tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
processedIds.Add(id);
@ -179,7 +199,9 @@ namespace UnityMCP.Editor
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50 ? commandText.Substring(0, 50) + "..." : commandText
receivedText = commandText.Length > 50
? commandText.Substring(0, 50) + "..."
: commandText,
};
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
processedIds.Add(id);
@ -194,7 +216,7 @@ namespace UnityMCP.Editor
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object"
details = "The command was valid JSON but could not be deserialized to a Command object",
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
@ -213,7 +235,9 @@ namespace UnityMCP.Editor
status = "error",
error = ex.Message,
commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50 ? commandText.Substring(0, 50) + "..." : commandText
receivedText = commandText?.Length > 50
? commandText.Substring(0, 50) + "..."
: commandText,
};
string responseJson = JsonConvert.SerializeObject(response);
tcs.SetResult(responseJson);
@ -236,8 +260,11 @@ namespace UnityMCP.Editor
return false;
text = text.Trim();
if ((text.StartsWith("{") && text.EndsWith("}")) || // Object
(text.StartsWith("[") && text.EndsWith("]"))) // Array
if (
(text.StartsWith("{") && text.EndsWith("}"))
|| // Object
(text.StartsWith("[") && text.EndsWith("]"))
) // Array
{
try
{
@ -263,7 +290,7 @@ namespace UnityMCP.Editor
{
status = "error",
error = "Command type cannot be empty",
details = "A valid command type is required for processing"
details = "A valid command type is required for processing",
};
return JsonConvert.SerializeObject(errorResponse);
}
@ -271,7 +298,11 @@ namespace UnityMCP.Editor
// Handle ping command for connection verification
if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase))
{
var pingResponse = new { status = "success", result = new { message = "pong" } };
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
return JsonConvert.SerializeObject(pingResponse);
}
@ -290,7 +321,9 @@ namespace UnityMCP.Editor
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject),
"execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException($"Unknown or unsupported command type: {command.type}")
_ => throw new ArgumentException(
$"Unknown or unsupported command type: {command.type}"
),
};
// Standard success response format
@ -300,7 +333,9 @@ namespace UnityMCP.Editor
catch (Exception ex)
{
// Log the detailed error in Unity for debugging
Debug.LogError($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}");
Debug.LogError(
$"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"
);
// Standard error response format
var response = new
@ -309,7 +344,9 @@ namespace UnityMCP.Editor
error = ex.Message, // Provide the specific error message
command = command?.type ?? "Unknown", // Include the command type if available
stackTrace = ex.StackTrace, // Include stack trace for detailed debugging
paramsSummary = command?.@params != null ? GetParamsSummary(command.@params) : "No parameters" // Summarize parameters for context
paramsSummary = command?.@params != null
? GetParamsSummary(command.@params)
: "No parameters", // Summarize parameters for context
};
return JsonConvert.SerializeObject(response);
}
@ -323,7 +360,14 @@ namespace UnityMCP.Editor
if (@params == null || !@params.HasValues)
return "No parameters";
return string.Join(", ", @params.Properties().Select(p => $"{p.Name}: {p.Value?.ToString()?.Substring(0, Math.Min(20, p.Value?.ToString()?.Length ?? 0))}"));
return string.Join(
", ",
@params
.Properties()
.Select(p =>
$"{p.Name}: {p.Value?.ToString()?.Substring(0, Math.Min(20, p.Value?.ToString()?.Length ?? 0))}"
)
);
}
catch
{

View File

@ -1,9 +1,9 @@
using UnityEngine;
using UnityEditor;
using System.Runtime.InteropServices;
using UnityMCP.Editor.Models;
using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Models;
namespace UnityMCP.Editor.Windows
namespace UnityMcpBridge.Editor.Windows
{
// Editor window to display manual configuration instructions
public class ManualConfigEditorWindow : EditorWindow
@ -33,36 +33,65 @@ namespace UnityMCP.Editor.Windows
// Header with improved styling
EditorGUILayout.Space(10);
Rect titleRect = EditorGUILayout.GetControlRect(false, 30);
EditorGUI.DrawRect(new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), new Color(0.2f, 0.2f, 0.2f, 0.1f));
GUI.Label(new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height),
mcpClient.name + " Manual Configuration", EditorStyles.boldLabel);
EditorGUI.DrawRect(
new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height),
new Color(0.2f, 0.2f, 0.2f, 0.1f)
);
GUI.Label(
new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height),
mcpClient.name + " Manual Configuration",
EditorStyles.boldLabel
);
EditorGUILayout.Space(10);
// Instructions with improved styling
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
Rect headerRect = EditorGUILayout.GetControlRect(false, 24);
EditorGUI.DrawRect(new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), new Color(0.1f, 0.1f, 0.1f, 0.2f));
GUI.Label(new Rect(headerRect.x + 8, headerRect.y + 4, headerRect.width - 16, headerRect.height),
"The automatic configuration failed. Please follow these steps:", EditorStyles.boldLabel);
EditorGUI.DrawRect(
new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height),
new Color(0.1f, 0.1f, 0.1f, 0.2f)
);
GUI.Label(
new Rect(
headerRect.x + 8,
headerRect.y + 4,
headerRect.width - 16,
headerRect.height
),
"The automatic configuration failed. Please follow these steps:",
EditorStyles.boldLabel
);
EditorGUILayout.Space(10);
GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel)
{
margin = new RectOffset(10, 10, 5, 5)
margin = new RectOffset(10, 10, 5, 5),
};
EditorGUILayout.LabelField("1. Open " + mcpClient.name + " config file by either:", instructionStyle);
EditorGUILayout.LabelField(
"1. Open " + mcpClient.name + " config file by either:",
instructionStyle
);
if (mcpClient.mcpType == McpTypes.ClaudeDesktop)
{
EditorGUILayout.LabelField(" a) Going to Settings > Developer > Edit Config", instructionStyle);
EditorGUILayout.LabelField(
" a) Going to Settings > Developer > Edit Config",
instructionStyle
);
}
else if (mcpClient.mcpType == McpTypes.Cursor)
{
EditorGUILayout.LabelField(" a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", instructionStyle);
EditorGUILayout.LabelField(
" a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server",
instructionStyle
);
}
EditorGUILayout.LabelField(" OR", instructionStyle);
EditorGUILayout.LabelField(" b) Opening the configuration file at:", instructionStyle);
EditorGUILayout.LabelField(
" b) Opening the configuration file at:",
instructionStyle
);
// Path section with improved styling
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
@ -71,7 +100,10 @@ namespace UnityMCP.Editor.Windows
{
displayPath = mcpClient.windowsConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
else if (
RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
)
{
displayPath = mcpClient.linuxConfigPath;
}
@ -81,12 +113,13 @@ namespace UnityMCP.Editor.Windows
}
// Prevent text overflow by allowing the text field to wrap
GUIStyle pathStyle = new(EditorStyles.textField)
{
wordWrap = true
};
GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true };
EditorGUILayout.TextField(displayPath, pathStyle, GUILayout.Height(EditorGUIUtility.singleLineHeight));
EditorGUILayout.TextField(
displayPath,
pathStyle,
GUILayout.Height(EditorGUIUtility.singleLineHeight)
);
// Copy button with improved styling
EditorGUILayout.BeginHorizontal();
@ -94,24 +127,40 @@ namespace UnityMCP.Editor.Windows
GUIStyle copyButtonStyle = new(GUI.skin.button)
{
padding = new RectOffset(15, 15, 5, 5),
margin = new RectOffset(10, 10, 5, 5)
margin = new RectOffset(10, 10, 5, 5),
};
if (GUILayout.Button("Copy Path", copyButtonStyle, GUILayout.Height(25), GUILayout.Width(100)))
if (
GUILayout.Button(
"Copy Path",
copyButtonStyle,
GUILayout.Height(25),
GUILayout.Width(100)
)
)
{
EditorGUIUtility.systemCopyBuffer = displayPath;
pathCopied = true;
copyFeedbackTimer = 2f;
}
if (GUILayout.Button("Open File", copyButtonStyle, GUILayout.Height(25), GUILayout.Width(100)))
if (
GUILayout.Button(
"Open File",
copyButtonStyle,
GUILayout.Height(25),
GUILayout.Width(100)
)
)
{
// Open the file using the system's default application
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo
{
FileName = displayPath,
UseShellExecute = true
});
UseShellExecute = true,
}
);
}
if (pathCopied)
@ -126,7 +175,10 @@ namespace UnityMCP.Editor.Windows
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("2. Paste the following JSON configuration:", instructionStyle);
EditorGUILayout.LabelField(
"2. Paste the following JSON configuration:",
instructionStyle
);
// JSON section with improved styling
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
@ -135,7 +187,7 @@ namespace UnityMCP.Editor.Windows
GUIStyle jsonStyle = new(EditorStyles.textArea)
{
font = EditorStyles.boldFont,
wordWrap = true
wordWrap = true,
};
jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue
@ -146,7 +198,14 @@ namespace UnityMCP.Editor.Windows
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Copy JSON", copyButtonStyle, GUILayout.Height(25), GUILayout.Width(100)))
if (
GUILayout.Button(
"Copy JSON",
copyButtonStyle,
GUILayout.Height(25),
GUILayout.Width(100)
)
)
{
EditorGUIUtility.systemCopyBuffer = configJson;
jsonCopied = true;
@ -164,7 +223,10 @@ namespace UnityMCP.Editor.Windows
EditorGUILayout.EndVertical();
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("3. Save the file and restart " + mcpClient.name, instructionStyle);
EditorGUILayout.LabelField(
"3. Save the file and restart " + mcpClient.name,
instructionStyle
);
EditorGUILayout.EndVertical();

View File

@ -1,21 +1,19 @@
using UnityEngine;
using UnityEditor;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System;
using Newtonsoft.Json;
using System.Net.Sockets;
using System.Threading.Tasks;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using UnityMCP.Editor.Models;
using UnityMCP.Editor.Data;
using System.IO;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
using UnityMcpBridge.Editor.Data;
using UnityMcpBridge.Editor.Models;
namespace UnityMCP.Editor.Windows
namespace UnityMcpBridge.Editor.Windows
{
public class UnityMCPEditorWindow : EditorWindow
public class UnityMcpEditorWindow : EditorWindow
{
private bool isUnityBridgeRunning = false;
private Vector2 scrollPosition;
@ -31,22 +29,51 @@ namespace UnityMCP.Editor.Windows
private List<string> possiblePaths = new()
{
Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python", "server.py")),
Path.GetFullPath(Path.Combine(Application.dataPath, "Packages", "com.justinpbarnett.unity-mcp", "Python", "server.py")),
Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Library", "PackageCache", "com.justinpbarnett.unity-mcp@*", "Python", "server.py")),
Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "com.justinpbarnett.unity-mcp", "Python", "server.py"))
Path.GetFullPath(
Path.Combine(Application.dataPath, "unity-mcp", "Python", "server.py")
),
Path.GetFullPath(
Path.Combine(
Application.dataPath,
"Packages",
"com.justinpbarnett.unity-mcp",
"Python",
"server.py"
)
),
Path.GetFullPath(
Path.Combine(
Application.dataPath,
"..",
"Library",
"PackageCache",
"com.justinpbarnett.unity-mcp@*",
"Python",
"server.py"
)
),
Path.GetFullPath(
Path.Combine(
Application.dataPath,
"..",
"Packages",
"com.justinpbarnett.unity-mcp",
"Python",
"server.py"
)
),
};
[MenuItem("Window/Unity MCP")]
public static void ShowWindow()
{
GetWindow<UnityMCPEditorWindow>("MCP Editor");
GetWindow<UnityMcpEditorWindow>("MCP Editor");
}
private void OnEnable()
{
// Check initial states
isUnityBridgeRunning = UnityMCPBridge.IsRunning;
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
CheckPythonServerConnection();
foreach (McpClient mcpClient in mcpClients.clients)
{
@ -107,7 +134,9 @@ namespace UnityMCP.Editor.Windows
{
// Received response but not the expected one
pythonServerStatus = "Invalid Server";
pythonServerStatusColor = GetStatusColor(McpStatus.CommunicationError);
pythonServerStatusColor = GetStatusColor(
McpStatus.CommunicationError
);
}
}
else
@ -115,7 +144,9 @@ namespace UnityMCP.Editor.Windows
// No response received
pythonServerStatus = "No Response";
pythonServerStatusColor = GetStatusColor(McpStatus.NoResponse);
UnityEngine.Debug.LogWarning($"Python server not responding on port {unityPort}");
UnityEngine.Debug.LogWarning(
$"Python server not responding on port {unityPort}"
);
}
}
catch (Exception e)
@ -123,7 +154,9 @@ namespace UnityMCP.Editor.Windows
// Connection established but communication failed
pythonServerStatus = "Communication Error";
pythonServerStatusColor = GetStatusColor(McpStatus.CommunicationError);
UnityEngine.Debug.LogWarning($"Error communicating with Python server: {e.Message}");
UnityEngine.Debug.LogWarning(
$"Error communicating with Python server: {e.Message}"
);
}
}
else
@ -131,7 +164,9 @@ namespace UnityMCP.Editor.Windows
// Connection failed
pythonServerStatus = "Not Connected";
pythonServerStatusColor = GetStatusColor(McpStatus.NotConfigured);
UnityEngine.Debug.LogWarning($"Python server is not running or not accessible on port {unityPort}");
UnityEngine.Debug.LogWarning(
$"Python server is not running or not accessible on port {unityPort}"
);
}
client.Close();
}
@ -155,7 +190,7 @@ namespace UnityMCP.Editor.Windows
McpStatus.IncorrectPath => Color.yellow,
McpStatus.CommunicationError => Color.yellow,
McpStatus.NoResponse => Color.yellow,
_ => Color.red // Default to red for error states or not configured
_ => Color.red, // Default to red for error states or not configured
};
}
@ -178,8 +213,16 @@ namespace UnityMCP.Editor.Windows
// Header with improved styling
EditorGUILayout.Space(5);
Rect headerRect = EditorGUILayout.GetControlRect(false, 24);
GUI.Label(new Rect(headerRect.x + 8, headerRect.y + 4, headerRect.width - 16, headerRect.height),
mcpClient.name + " Configuration", EditorStyles.boldLabel);
GUI.Label(
new Rect(
headerRect.x + 8,
headerRect.y + 4,
headerRect.width - 16,
headerRect.height
),
mcpClient.name + " Configuration",
EditorStyles.boldLabel
);
EditorGUILayout.Space(5);
// Status indicator with colored dot
@ -190,7 +233,11 @@ namespace UnityMCP.Editor.Windows
DrawStatusDot(statusRect, statusColor);
// Status text with some padding
EditorGUILayout.LabelField(new GUIContent(" " + mcpClient.configStatus), GUILayout.Height(20), GUILayout.MinWidth(100));
EditorGUILayout.LabelField(
new GUIContent(" " + mcpClient.configStatus),
GUILayout.Height(20),
GUILayout.MinWidth(100)
);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8);
@ -203,7 +250,13 @@ namespace UnityMCP.Editor.Windows
// Create muted button style for Manual Setup
GUIStyle mutedButtonStyle = new(buttonStyle);
if (GUILayout.Button($"Auto Configure {mcpClient.name}", buttonStyle, GUILayout.Height(28)))
if (
GUILayout.Button(
$"Auto Configure {mcpClient.name}",
buttonStyle,
GUILayout.Height(28)
)
)
{
ConfigureMcpClient(mcpClient);
}
@ -227,7 +280,10 @@ namespace UnityMCP.Editor.Windows
EditorGUILayout.Space(5);
}
// Add space and end the horizontal layout if last item is odd
else if (useHalfWidth && mcpClients.clients.IndexOf(mcpClient) == mcpClients.clients.Count - 1)
else if (
useHalfWidth
&& mcpClients.clients.IndexOf(mcpClient) == mcpClients.clients.Count - 1
)
{
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(5);
@ -245,7 +301,11 @@ namespace UnityMCP.Editor.Windows
Handles.DrawSolidDisc(center, Vector3.forward, radius);
// Draw the border
Color borderColor = new(statusColor.r * 0.7f, statusColor.g * 0.7f, statusColor.b * 0.7f);
Color borderColor = new(
statusColor.r * 0.7f,
statusColor.g * 0.7f,
statusColor.b * 0.7f
);
Handles.color = borderColor;
Handles.DrawWireDisc(center, Vector3.forward, radius);
}
@ -257,9 +317,15 @@ namespace UnityMCP.Editor.Windows
EditorGUILayout.Space(10);
// Title with improved styling
Rect titleRect = EditorGUILayout.GetControlRect(false, 30);
EditorGUI.DrawRect(new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), new Color(0.2f, 0.2f, 0.2f, 0.1f));
GUI.Label(new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height),
"MCP Editor", EditorStyles.boldLabel);
EditorGUI.DrawRect(
new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height),
new Color(0.2f, 0.2f, 0.2f, 0.1f)
);
GUI.Label(
new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height),
"MCP Editor",
EditorStyles.boldLabel
);
EditorGUILayout.Space(10);
// Python Server Status Section
@ -274,7 +340,10 @@ namespace UnityMCP.Editor.Windows
EditorGUILayout.LabelField($"Unity Port: {unityPort}");
EditorGUILayout.LabelField($"MCP Port: {mcpPort}");
EditorGUILayout.HelpBox("Your MCP client (e.g. Cursor or Claude Desktop) will start the server automatically when you start it.", MessageType.Info);
EditorGUILayout.HelpBox(
"Your MCP client (e.g. Cursor or Claude Desktop) will start the server automatically when you start it.",
MessageType.Info
);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(10);
@ -304,23 +373,23 @@ namespace UnityMCP.Editor.Windows
{
if (isUnityBridgeRunning)
{
UnityMCPBridge.Stop();
UnityMcpBridge.Stop();
}
else
{
UnityMCPBridge.Start();
UnityMcpBridge.Start();
}
isUnityBridgeRunning = !isUnityBridgeRunning;
}
private string GetPythonDirectory(List<string> possiblePaths)
{
foreach (var path in possiblePaths)
{
// Skip wildcard paths for now
if (path.Contains("*")) continue;
if (path.Contains("*"))
continue;
if (File.Exists(path))
{
@ -330,7 +399,8 @@ namespace UnityMCP.Editor.Windows
foreach (var path in possiblePaths)
{
if (!path.Contains("*")) continue;
if (!path.Contains("*"))
continue;
string directoryPath = Path.GetDirectoryName(path);
string searchPattern = Path.GetFileName(Path.GetDirectoryName(path));
@ -358,22 +428,13 @@ namespace UnityMCP.Editor.Windows
private string WriteToConfig(string pythonDir, string configPath)
{
// Create configuration object for unityMCP
var unityMCPConfig = new MCPConfigServer
var unityMCPConfig = new McpConfigServer
{
command = "uv",
args = new[]
{
"--directory",
pythonDir,
"run",
"server.py"
}
args = new[] { "--directory", pythonDir, "run", "server.py" },
};
var jsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
var jsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented };
// Read existing config if it exists
string existingJson = "{}";
@ -403,7 +464,8 @@ namespace UnityMCP.Editor.Windows
}
// Add/update unityMCP while preserving other servers
existingConfig.mcpServers.unityMCP = JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JToken>(
existingConfig.mcpServers.unityMCP =
JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JToken>(
JsonConvert.SerializeObject(unityMCPConfig)
);
@ -428,28 +490,19 @@ namespace UnityMCP.Editor.Windows
string pythonDir = FindPackagePythonDirectory();
// Create the manual configuration message
var jsonConfig = new MCPConfig
var jsonConfig = new McpConfig
{
mcpServers = new MCPConfigServers
mcpServers = new McpConfigServers
{
unityMCP = new MCPConfigServer
unityMCP = new McpConfigServer
{
command = "uv",
args = new[]
{
"--directory",
pythonDir,
"run",
"server.py"
}
}
}
args = new[] { "--directory", pythonDir, "run", "server.py" },
},
},
};
var jsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
var jsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented };
string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient);
@ -474,8 +527,10 @@ namespace UnityMCP.Editor.Windows
string packagePath = package.resolvedPath;
string potentialPythonDir = Path.Combine(packagePath, "Python");
if (Directory.Exists(potentialPythonDir) &&
File.Exists(Path.Combine(potentialPythonDir, "server.py")))
if (
Directory.Exists(potentialPythonDir)
&& File.Exists(Path.Combine(potentialPythonDir, "server.py"))
)
{
return potentialPythonDir;
}
@ -489,8 +544,9 @@ namespace UnityMCP.Editor.Windows
// If not found via Package Manager, try manual approaches
// First check for local installation
string[] possibleDirs = {
Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python"))
string[] possibleDirs =
{
Path.GetFullPath(Path.Combine(Application.dataPath, "unity-mcp", "Python")),
};
foreach (var dir in possibleDirs)
@ -502,7 +558,9 @@ namespace UnityMCP.Editor.Windows
}
// If still not found, return the placeholder path
UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path");
UnityEngine.Debug.LogWarning(
"Could not find Python directory, using placeholder path"
);
}
catch (Exception e)
{
@ -523,7 +581,10 @@ namespace UnityMCP.Editor.Windows
{
configPath = mcpClient.windowsConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
else if (
RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
)
{
configPath = mcpClient.linuxConfigPath;
}
@ -562,19 +623,26 @@ namespace UnityMCP.Editor.Windows
{
configPath = mcpClient.windowsConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
else if (
RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
)
{
configPath = mcpClient.linuxConfigPath;
}
ShowManualInstructionsWindow(configPath, mcpClient);
UnityEngine.Debug.LogError($"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}");
UnityEngine.Debug.LogError(
$"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}"
);
return $"Failed to configure {mcpClient.name}";
}
}
private void ShowCursorManualConfigurationInstructions(string configPath, McpClient mcpClient)
private void ShowCursorManualConfigurationInstructions(
string configPath,
McpClient mcpClient
)
{
mcpClient.SetStatus(McpStatus.Error, "Manual configuration required");
@ -582,28 +650,19 @@ namespace UnityMCP.Editor.Windows
string pythonDir = FindPackagePythonDirectory();
// Create the manual configuration message
var jsonConfig = new MCPConfig
var jsonConfig = new McpConfig
{
mcpServers = new MCPConfigServers
mcpServers = new McpConfigServers
{
unityMCP = new MCPConfigServer
unityMCP = new McpConfigServer
{
command = "uv",
args = new[]
{
"--directory",
pythonDir,
"run",
"server.py"
}
}
}
args = new[] { "--directory", pythonDir, "run", "server.py" },
},
},
};
var jsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
var jsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented };
string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient);
@ -618,7 +677,10 @@ namespace UnityMCP.Editor.Windows
{
configPath = mcpClient.windowsConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
else if (
RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
|| RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
)
{
configPath = mcpClient.linuxConfigPath;
}
@ -635,12 +697,18 @@ namespace UnityMCP.Editor.Windows
}
string configJson = File.ReadAllText(configPath);
var config = JsonConvert.DeserializeObject<MCPConfig>(configJson);
var config = JsonConvert.DeserializeObject<McpConfig>(configJson);
if (config?.mcpServers?.unityMCP != null)
{
string pythonDir = GetPythonDirectory(possiblePaths);
if (pythonDir != null && Array.Exists(config.mcpServers.unityMCP.args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)))
if (
pythonDir != null
&& Array.Exists(
config.mcpServers.unityMCP.args,
arg => arg.Contains(pythonDir, StringComparison.Ordinal)
)
)
{
mcpClient.SetStatus(McpStatus.Configured);
}

View File

@ -0,0 +1,10 @@
{
"name": "com.justinpbarnett.unity-mcp",
"version": "2.0.0",
"displayName": "Unity MCP Bridge",
"description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.",
"unity": "2020.3",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.0.2"
}
}

View File

@ -1,6 +1,6 @@
[project]
name = "unity-mcp"
version = "1.0.1"
name = "unity-mcp-server"
version = "2.0.0"
description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md"
requires-python = ">=3.12"

View File

@ -12,7 +12,7 @@ logging.basicConfig(
level=getattr(logging, config.log_level),
format=config.log_format
)
logger = logging.getLogger("UnityMCP")
logger = logging.getLogger("unity-mcp-server")
# Global connection state
_unity_connection: UnityConnection = None
@ -21,7 +21,7 @@ _unity_connection: UnityConnection = None
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"""Handle server startup and shutdown."""
global _unity_connection
logger.info("UnityMCP server starting up")
logger.info("Unity MCP Server starting up")
try:
_unity_connection = get_unity_connection()
logger.info("Connected to Unity on startup")
@ -36,11 +36,11 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
if _unity_connection:
_unity_connection.disconnect()
_unity_connection = None
logger.info("UnityMCP server shut down")
logger.info("Unity MCP Server shut down")
# Initialize MCP server
mcp = FastMCP(
"UnityMCP",
"unity-mcp-server",
description="Unity Editor integration via Model Context Protocol",
lifespan=server_lifespan
)

View File

@ -8,7 +8,7 @@ from .execute_menu_item import register_execute_menu_item_tools
def register_all_tools(mcp):
"""Register all refactored tools with the MCP server."""
print("Registering UnityMCP refactored tools...")
print("Registering Unity MCP Server refactored tools...")
register_manage_script_tools(mcp)
register_manage_scene_tools(mcp)
register_manage_editor_tools(mcp)
@ -16,4 +16,4 @@ def register_all_tools(mcp):
register_manage_asset_tools(mcp)
register_read_console_tools(mcp)
register_execute_menu_item_tools(mcp)
print("UnityMCP tool registration complete.")
print("Unity MCP Server tool registration complete.")

View File

@ -10,7 +10,7 @@ logging.basicConfig(
level=getattr(logging, config.log_level),
format=config.log_format
)
logger = logging.getLogger("UnityMCP")
logger = logging.getLogger("unity-mcp-server")
@dataclass
class UnityConnection:

View File

@ -322,7 +322,7 @@ wheels = [
[[package]]
name = "unity-mcp"
version = "0.1.0"
version = "1.0.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },

View File

@ -1,10 +0,0 @@
{
"name": "com.justinpbarnett.unity-mcp",
"version": "1.0.1",
"displayName": "Unity MCP",
"description": "A Unity package to communicate with a local MCP Client via a Python server.",
"unity": "2020.3",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.0.2"
}
}

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 1429c59739af3fc4d8b706950221d476
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: