升级XR插件版本

This commit is contained in:
Sora丶kong
2026-03-02 17:56:21 +08:00
parent 8962657674
commit 60f512a9bc
1317 changed files with 110305 additions and 48249 deletions

View File

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

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using UnityEditor;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class AntigravityConfigurator : JsonFileMcpConfigurator
{
public AntigravityConfigurator() : base(new McpClient
{
name = "Antigravity",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"),
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Antigravity",
"Click the more_horiz menu in the Agent pane > MCP Servers",
"Select 'Install' for Unity MCP or use the Configure button above",
"Restart Antigravity if necessary"
};
}
}

View File

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

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class CherryStudioConfigurator : JsonFileMcpConfigurator
{
public const string ClientName = "Cherry Studio";
public CherryStudioConfigurator() : base(new McpClient
{
name = ClientName,
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Cherry Studio", "config"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Cherry Studio", "config"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Cherry Studio", "config"),
SupportsHttpTransport = false
})
{ }
public override bool SupportsAutoConfigure => false;
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Cherry Studio",
"Go to Settings (⚙️) → MCP Server",
"Click 'Add Server' button",
"For STDIO mode (recommended):",
" - Name: unity-mcp",
" - Type: STDIO",
" - Command: uvx",
" - Arguments: Copy from the Manual Configuration JSON below",
"Click Save and restart Cherry Studio",
"",
"Note: Cherry Studio uses UI-based configuration.",
"Use the manual snippet below as reference for the values to enter."
};
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
client.SetStatus(McpStatus.NotConfigured, "Cherry Studio requires manual UI configuration");
return client.status;
}
public override void Configure()
{
throw new InvalidOperationException(
"Cherry Studio uses UI-based configuration. " +
"Please use the Manual Configuration snippet and Installation Steps to configure manually."
);
}
public override string GetManualSnippet()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (useHttp)
{
return "# Cherry Studio does not support WebSocket transport.\n" +
"# Cherry Studio supports STDIO and SSE transports.\n" +
"# \n" +
"# To use Cherry Studio:\n" +
"# 1. Switch transport to 'Stdio' in Advanced Settings below\n" +
"# 2. Return to this configuration screen\n" +
"# 3. Copy the STDIO configuration snippet that will appear\n" +
"# \n" +
"# OPTION 2: SSE mode (future support)\n" +
"# Note: Unity MCP does not currently have an SSE endpoint.\n" +
"# This may be added in a future update.";
}
return base.GetManualSnippet() + "\n\n" +
"# Cherry Studio Configuration Instructions:\n" +
"# Cherry Studio uses UI-based configuration, not a JSON file.\n" +
"# \n" +
"# To configure:\n" +
"# 1. Open Cherry Studio\n" +
"# 2. Go to Settings (⚙️) → MCP Server\n" +
"# 3. Click 'Add Server'\n" +
"# 4. Enter the following values from the JSON above:\n" +
"# - Name: unity-mcp\n" +
"# - Type: STDIO\n" +
"# - Command: (copy 'command' value from JSON)\n" +
"# - Arguments: (copy 'args' array values, space-separated or as individual entries)\n" +
"# - Active: true\n" +
"# 5. Click Save\n" +
"# 6. Restart Cherry Studio";
}
}
}

View File

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

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
/// <summary>
/// Claude Code configurator using the CLI-based registration (claude mcp add/remove).
/// This integrates with Claude Code's native MCP management.
/// </summary>
public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator
{
public ClaudeCodeConfigurator() : base(new McpClient
{
name = "Claude Code",
SupportsHttpTransport = true,
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Ensure Claude CLI is installed (comes with Claude Code)",
"Click Register to add UnityMCP via 'claude mcp add'",
"The server will be automatically available in Claude Code",
"Use Unregister to remove via 'claude mcp remove'"
};
}
}

View File

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

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class ClaudeDesktopConfigurator : JsonFileMcpConfigurator
{
public const string ClientName = "Claude Desktop";
public ClaudeDesktopConfigurator() : base(new McpClient
{
name = ClientName,
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json"),
SupportsHttpTransport = false,
StripEnvWhenNotRequired = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Claude Desktop",
"Go to Settings > Developer > Edit Config\nOR open the config path",
"Paste the configuration JSON",
"Save and restart Claude Desktop"
};
public override void Configure()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (useHttp)
{
throw new InvalidOperationException("Claude Desktop does not support HTTP transport. Switch to stdio in settings before configuring.");
}
base.Configure();
}
public override string GetManualSnippet()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (useHttp)
{
return "# Claude Desktop does not support HTTP transport.\n" +
"# Open Advanced Settings and disable HTTP transport to use stdio, then regenerate.";
}
return base.GetManualSnippet();
}
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
/// <summary>
/// Configures the CodeBuddy CLI (~/.codebuddy.json) MCP settings.
/// </summary>
public class CodeBuddyCliConfigurator : JsonFileMcpConfigurator
{
public CodeBuddyCliConfigurator() : base(new McpClient
{
name = "CodeBuddy CLI",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"),
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install CodeBuddy CLI and ensure '~/.codebuddy.json' exists",
"Click Configure to add the UnityMCP entry (or manually edit the file above)",
"Restart your CLI session if needed"
};
}
}

View File

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

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class CodexConfigurator : CodexMcpConfigurator
{
public CodexConfigurator() : base(new McpClient
{
name = "Codex",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml")
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Run 'codex config edit' in a terminal\nOR open the config file at the path above",
"Paste the configuration TOML",
"Save and restart Codex"
};
}
}

View File

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

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class CopilotCliConfigurator : JsonFileMcpConfigurator
{
public CopilotCliConfigurator() : base(new McpClient
{
name = "GitHub Copilot CLI",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json")
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install GitHub Copilot CLI (https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)",
"Open or create mcp-config.json at the path above",
"Paste the configuration JSON (or use /mcp add in the CLI)",
"Restart your Copilot CLI session"
};
}
}

View File

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

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class CursorConfigurator : JsonFileMcpConfigurator
{
public CursorConfigurator() : base(new McpClient
{
name = "Cursor",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json")
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Cursor",
"Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\nOR open the config file at the path above",
"Paste the configuration JSON",
"Save and restart Cursor"
};
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class KiloCodeConfigurator : JsonFileMcpConfigurator
{
public KiloCodeConfigurator() : base(new McpClient
{
name = "Kilo Code",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
IsVsCodeLayout = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install Kilo Code extension in VS Code",
"Open Kilo Code settings (gear icon in sidebar)",
"Navigate to MCP Servers section and click 'Edit Global MCP Settings'\nOR open the config file at the path above",
"Paste the configuration JSON into the mcpServers object",
"Save and restart VS Code"
};
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class KiroConfigurator : JsonFileMcpConfigurator
{
public KiroConfigurator() : base(new McpClient
{
name = "Kiro",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"),
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Kiro",
"Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\nOR open the config file at the path above",
"Paste the configuration JSON",
"Save and restart Kiro"
};
}
}

View File

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

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Clients.Configurators
{
/// <summary>
/// Configurator for OpenCode (opencode.ai) - a Go-based terminal AI coding assistant.
/// OpenCode uses ~/.config/opencode/opencode.json with a custom "mcp" format.
/// </summary>
public class OpenCodeConfigurator : McpClientConfiguratorBase
{
private const string ServerName = "unityMCP";
private const string SchemaUrl = "https://opencode.ai/config.json";
public OpenCodeConfigurator() : base(new McpClient
{
name = "OpenCode",
windowsConfigPath = BuildConfigPath(),
macConfigPath = BuildConfigPath(),
linuxConfigPath = BuildConfigPath()
})
{ }
private static string BuildConfigPath()
{
string xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
string configBase = !string.IsNullOrEmpty(xdgConfigHome)
? xdgConfigHome
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
return Path.Combine(configBase, "opencode", "opencode.json");
}
public override string GetConfigPath() => CurrentOsPath();
/// <summary>
/// Attempts to load and parse the config file.
/// Returns null if file doesn't exist or cannot be read.
/// Returns parsed JObject if valid JSON found.
/// Logs warning if file exists but contains malformed JSON.
/// </summary>
private JObject TryLoadConfig(string path)
{
if (!File.Exists(path))
return null;
string content;
try
{
content = File.ReadAllText(path);
}
catch (Exception ex)
{
UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Failed to read config file {path}: {ex.Message}");
return null;
}
try
{
return JsonConvert.DeserializeObject<JObject>(content) ?? new JObject();
}
catch (JsonException ex)
{
// Malformed JSON - log warning and return null.
// When Configure() receives null, it will do: TryLoadConfig(path) ?? new JObject()
// This creates a fresh empty JObject, which replaces the entire file with only the unityMCP section.
// Existing config sections are lost. To preserve sections, a different recovery strategy
// (e.g., line-by-line parsing, JSON repair, or manual user intervention) would be needed.
UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Malformed JSON in {path}: {ex.Message}");
return null;
}
}
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
try
{
string path = GetConfigPath();
var config = TryLoadConfig(path);
if (config == null)
{
client.SetStatus(McpStatus.NotConfigured);
return client.status;
}
var unityMcp = config["mcp"]?[ServerName] as JObject;
if (unityMcp == null)
{
client.SetStatus(McpStatus.NotConfigured);
return client.status;
}
string configuredUrl = unityMcp["url"]?.ToString();
string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
if (UrlsEqual(configuredUrl, expectedUrl))
{
client.SetStatus(McpStatus.Configured);
}
else if (attemptAutoRewrite)
{
Configure();
}
else
{
client.SetStatus(McpStatus.IncorrectPath);
}
}
catch (Exception ex)
{
client.SetStatus(McpStatus.Error, ex.Message);
}
return client.status;
}
public override void Configure()
{
try
{
string path = GetConfigPath();
McpConfigurationHelper.EnsureConfigDirectoryExists(path);
// Load existing config or start fresh, preserving all other properties and MCP servers
var config = TryLoadConfig(path) ?? new JObject();
// Only add $schema if creating a new file
if (!File.Exists(path))
{
config["$schema"] = SchemaUrl;
}
// Preserve existing mcp section and only update our server entry
var mcpSection = config["mcp"] as JObject ?? new JObject();
config["mcp"] = mcpSection;
mcpSection[ServerName] = BuildServerEntry();
McpConfigurationHelper.WriteAtomicFile(path, JsonConvert.SerializeObject(config, Formatting.Indented));
client.SetStatus(McpStatus.Configured);
}
catch (Exception ex)
{
client.SetStatus(McpStatus.Error, ex.Message);
}
}
public override string GetManualSnippet()
{
var snippet = new JObject
{
["mcp"] = new JObject { [ServerName] = BuildServerEntry() }
};
return JsonConvert.SerializeObject(snippet, Formatting.Indented);
}
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install OpenCode (https://opencode.ai)",
"Click Configure to add Unity MCP to ~/.config/opencode/opencode.json",
"Restart OpenCode",
"The Unity MCP server should be detected automatically"
};
private static JObject BuildServerEntry() => new JObject
{
["type"] = "remote",
["url"] = HttpEndpointUtility.GetMcpRpcUrl(),
["enabled"] = true
};
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class RiderConfigurator : JsonFileMcpConfigurator
{
public RiderConfigurator() : base(new McpClient
{
name = "Rider GitHub Copilot",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "github-copilot", "intellij", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "github-copilot", "intellij", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "github-copilot", "intellij", "mcp.json"),
IsVsCodeLayout = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install GitHub Copilot plugin in Rider",
"Open or create mcp.json at the path above",
"Paste the configuration JSON",
"Save and restart Rider"
};
}
}

View File

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

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class TraeConfigurator : JsonFileMcpConfigurator
{
public TraeConfigurator() : base(new McpClient
{
name = "Trae",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Trae", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Trae", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Trae", "mcp.json"),
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Trae and go to Settings > MCP",
"Select Add Server > Add Manually",
"Paste the JSON or point to the mcp.json file\n"+
"Windows: %AppData%\\Trae\\mcp.json\n" +
"macOS: ~/Library/Application Support/Trae/mcp.json\n" +
"Linux: ~/.config/Trae/mcp.json\n",
"Save and restart Trae"
};
}
}

View File

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

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class VSCodeConfigurator : JsonFileMcpConfigurator
{
public VSCodeConfigurator() : base(new McpClient
{
name = "VSCode GitHub Copilot",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "mcp.json"),
IsVsCodeLayout = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install GitHub Copilot extension",
"Open or create mcp.json at the path above",
"Paste the configuration JSON",
"Save and restart VSCode"
};
}
}

View File

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

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class VSCodeInsidersConfigurator : JsonFileMcpConfigurator
{
public VSCodeInsidersConfigurator() : base(new McpClient
{
name = "VSCode Insiders GitHub Copilot",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code - Insiders", "User", "mcp.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code - Insiders", "User", "mcp.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code - Insiders", "User", "mcp.json"),
IsVsCodeLayout = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Install GitHub Copilot extension in VS Code Insiders",
"Open or create mcp.json at the path above",
"Paste the configuration JSON",
"Save and restart VS Code Insiders"
};
}
}

View File

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

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients.Configurators
{
public class WindsurfConfigurator : JsonFileMcpConfigurator
{
public WindsurfConfigurator() : base(new McpClient
{
name = "Windsurf",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"),
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
})
{ }
public override IList<string> GetInstallationSteps() => new List<string>
{
"Open Windsurf",
"Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\nOR open the config file at the path above",
"Paste the configuration JSON",
"Save and restart Windsurf"
};
}
}

View File

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

View File

@@ -0,0 +1,47 @@
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Clients
{
/// <summary>
/// Contract for MCP client configurators. Each client is responsible for
/// status detection, auto-configure, and manual snippet/steps.
/// </summary>
public interface IMcpClientConfigurator
{
/// <summary>Stable identifier (e.g., "cursor").</summary>
string Id { get; }
/// <summary>Display name shown in the UI.</summary>
string DisplayName { get; }
/// <summary>Current status cached by the configurator.</summary>
McpStatus Status { get; }
/// <summary>
/// The transport type the client is currently configured for.
/// Returns Unknown if the client is not configured or the transport cannot be determined.
/// </summary>
ConfiguredTransport ConfiguredTransport { get; }
/// <summary>True if this client supports auto-configure.</summary>
bool SupportsAutoConfigure { get; }
/// <summary>Label to show on the configure button for the current state.</summary>
string GetConfigureActionLabel();
/// <summary>Returns the platform-specific config path (or message for CLI-managed clients).</summary>
string GetConfigPath();
/// <summary>Checks and updates status; returns current status.</summary>
McpStatus CheckStatus(bool attemptAutoRewrite = true);
/// <summary>Runs auto-configuration (register/write file/CLI etc.).</summary>
void Configure();
/// <summary>Returns the manual configuration snippet (JSON/TOML/commands).</summary>
string GetManualSnippet();
/// <summary>Returns ordered human-readable installation steps.</summary>
System.Collections.Generic.IList<string> GetInstallationSteps();
}
}

View File

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

View File

@@ -0,0 +1,925 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Clients
{
/// <summary>Shared base class for MCP configurators.</summary>
public abstract class McpClientConfiguratorBase : IMcpClientConfigurator
{
protected readonly McpClient client;
protected McpClientConfiguratorBase(McpClient client)
{
this.client = client;
}
internal McpClient Client => client;
public string Id => client.name.Replace(" ", "").ToLowerInvariant();
public virtual string DisplayName => client.name;
public McpStatus Status => client.status;
public ConfiguredTransport ConfiguredTransport => client.configuredTransport;
public virtual bool SupportsAutoConfigure => true;
public virtual string GetConfigureActionLabel() => "Configure";
public abstract string GetConfigPath();
public abstract McpStatus CheckStatus(bool attemptAutoRewrite = true);
public abstract void Configure();
public abstract string GetManualSnippet();
public abstract IList<string> GetInstallationSteps();
protected string GetUvxPathOrError()
{
string uvx = MCPServiceLocator.Paths.GetUvxPath();
if (string.IsNullOrEmpty(uvx))
{
throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings.");
}
return uvx;
}
protected string CurrentOsPath()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return client.windowsConfigPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return client.macConfigPath;
return client.linuxConfigPath;
}
protected bool UrlsEqual(string a, string b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
return false;
}
if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) &&
Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB))
{
return Uri.Compare(
uriA,
uriB,
UriComponents.HttpRequestUrl,
UriFormat.SafeUnescaped,
StringComparison.OrdinalIgnoreCase) == 0;
}
string Normalize(string value) => value.Trim().TrimEnd('/');
return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>JSON-file based configurator (Cursor, Windsurf, VS Code, etc.).</summary>
public abstract class JsonFileMcpConfigurator : McpClientConfiguratorBase
{
public JsonFileMcpConfigurator(McpClient client) : base(client) { }
public override string GetConfigPath() => CurrentOsPath();
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
try
{
string path = GetConfigPath();
if (!File.Exists(path))
{
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
return client.status;
}
string configJson = File.ReadAllText(path);
string[] args = null;
string configuredUrl = null;
bool configExists = false;
if (client.IsVsCodeLayout)
{
var vsConfig = JsonConvert.DeserializeObject<JToken>(configJson) as JObject;
if (vsConfig != null)
{
var unityToken =
vsConfig["servers"]?["unityMCP"]
?? vsConfig["mcp"]?["servers"]?["unityMCP"];
if (unityToken is JObject unityObj)
{
configExists = true;
var argsToken = unityObj["args"];
if (argsToken is JArray)
{
args = argsToken.ToObject<string[]>();
}
var urlToken = unityObj["url"] ?? unityObj["serverUrl"];
if (urlToken != null && urlToken.Type != JTokenType.Null)
{
configuredUrl = urlToken.ToString();
}
}
}
}
else
{
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson);
if (standardConfig?.mcpServers?.unityMCP != null)
{
args = standardConfig.mcpServers.unityMCP.args;
configuredUrl = standardConfig.mcpServers.unityMCP.url;
configExists = true;
}
}
if (!configExists)
{
client.SetStatus(McpStatus.MissingConfig);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
return client.status;
}
// Determine and set the configured transport type
if (args != null && args.Length > 0)
{
client.configuredTransport = Models.ConfiguredTransport.Stdio;
}
else if (!string.IsNullOrEmpty(configuredUrl))
{
// Distinguish HTTP Local from HTTP Remote by matching against both URLs
string localRpcUrl = HttpEndpointUtility.GetLocalMcpRpcUrl();
string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl();
if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(configuredUrl, remoteRpcUrl))
{
client.configuredTransport = Models.ConfiguredTransport.HttpRemote;
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Http;
}
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
bool matches = false;
if (args != null && args.Length > 0)
{
string expectedUvxUrl = AssetPathUtility.GetMcpServerPackageSource();
string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args);
matches = !string.IsNullOrEmpty(configuredUvxUrl) &&
McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl);
}
else if (!string.IsNullOrEmpty(configuredUrl))
{
// Match against the active scope's URL
string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
matches = UrlsEqual(configuredUrl, expectedUrl);
}
if (matches)
{
client.SetStatus(McpStatus.Configured);
return client.status;
}
if (attemptAutoRewrite)
{
var result = McpConfigurationHelper.WriteMcpConfiguration(path, client);
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}
else
{
client.SetStatus(McpStatus.IncorrectPath);
}
}
else
{
client.SetStatus(McpStatus.IncorrectPath);
}
}
catch (Exception ex)
{
client.SetStatus(McpStatus.Error, ex.Message);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
return client.status;
}
public override void Configure()
{
string path = GetConfigPath();
McpConfigurationHelper.EnsureConfigDirectoryExists(path);
string result = McpConfigurationHelper.WriteMcpConfiguration(path, client);
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}
else
{
throw new InvalidOperationException(result);
}
}
public override string GetManualSnippet()
{
try
{
string uvx = GetUvxPathOrError();
return ConfigJsonBuilder.BuildManualConfigJson(uvx, client);
}
catch (Exception ex)
{
var errorObj = new { error = ex.Message };
return JsonConvert.SerializeObject(errorObj);
}
}
public override IList<string> GetInstallationSteps() => new List<string> { "Configuration steps not available for this client." };
}
/// <summary>Codex (TOML) configurator.</summary>
public abstract class CodexMcpConfigurator : McpClientConfiguratorBase
{
public CodexMcpConfigurator(McpClient client) : base(client) { }
public override string GetConfigPath() => CurrentOsPath();
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
try
{
string path = GetConfigPath();
if (!File.Exists(path))
{
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
return client.status;
}
string toml = File.ReadAllText(path);
if (CodexConfigHelper.TryParseCodexServer(toml, out _, out var args, out var url))
{
// Determine and set the configured transport type
if (!string.IsNullOrEmpty(url))
{
// Distinguish HTTP Local from HTTP Remote
string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl();
if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(url, remoteRpcUrl))
{
client.configuredTransport = Models.ConfiguredTransport.HttpRemote;
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Http;
}
}
else if (args != null && args.Length > 0)
{
client.configuredTransport = Models.ConfiguredTransport.Stdio;
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
bool matches = false;
if (!string.IsNullOrEmpty(url))
{
// Match against the active scope's URL
matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl());
}
else if (args != null && args.Length > 0)
{
string expected = AssetPathUtility.GetMcpServerPackageSource();
string configured = McpConfigurationHelper.ExtractUvxUrl(args);
matches = !string.IsNullOrEmpty(configured) &&
McpConfigurationHelper.PathsEqual(configured, expected);
}
if (matches)
{
client.SetStatus(McpStatus.Configured);
return client.status;
}
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
if (attemptAutoRewrite)
{
string result = McpConfigurationHelper.ConfigureCodexClient(path, client);
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}
else
{
client.SetStatus(McpStatus.IncorrectPath);
}
}
else
{
client.SetStatus(McpStatus.IncorrectPath);
}
}
catch (Exception ex)
{
client.SetStatus(McpStatus.Error, ex.Message);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
return client.status;
}
public override void Configure()
{
string path = GetConfigPath();
McpConfigurationHelper.EnsureConfigDirectoryExists(path);
string result = McpConfigurationHelper.ConfigureCodexClient(path, client);
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}
else
{
throw new InvalidOperationException(result);
}
}
public override string GetManualSnippet()
{
try
{
string uvx = GetUvxPathOrError();
return CodexConfigHelper.BuildCodexServerBlock(uvx);
}
catch (Exception ex)
{
return $"# error: {ex.Message}";
}
}
public override IList<string> GetInstallationSteps() => new List<string>
{
"Run 'codex config edit' or open the config path",
"Paste the TOML",
"Save and restart Codex"
};
}
/// <summary>CLI-based configurator (Claude Code).</summary>
public abstract class ClaudeCliMcpConfigurator : McpClientConfiguratorBase
{
public ClaudeCliMcpConfigurator(McpClient client) : base(client) { }
public override bool SupportsAutoConfigure => true;
public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Register";
public override string GetConfigPath() => "Managed via Claude CLI";
/// <summary>
/// Checks the Claude CLI registration status.
/// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access.
/// </summary>
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
// Capture main-thread-only values before delegating to thread-safe method
string projectDir = Path.GetDirectoryName(Application.dataPath);
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
// Resolve claudePath on the main thread (EditorPrefs access)
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite);
}
/// <summary>
/// Internal thread-safe version of CheckStatus.
/// Can be called from background threads because all main-thread-only values are passed as parameters.
/// projectDir, useHttpTransport, and claudePath are REQUIRED (non-nullable) to enforce thread safety at compile time.
/// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread.
/// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration
/// on the main thread based on the returned status.
/// </summary>
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = false)
{
try
{
if (string.IsNullOrEmpty(claudePath))
{
client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found");
client.configuredTransport = Models.ConfiguredTransport.Unknown;
return client.status;
}
// projectDir is required - no fallback to Application.dataPath
if (string.IsNullOrEmpty(projectDir))
{
throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution");
}
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
{
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
}
else if (Application.platform == RuntimePlatform.LinuxEditor)
{
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
}
try
{
string claudeDir = Path.GetDirectoryName(claudePath);
if (!string.IsNullOrEmpty(claudeDir))
{
pathPrepend = string.IsNullOrEmpty(pathPrepend)
? claudeDir
: $"{claudeDir}:{pathPrepend}";
}
}
catch { }
// Check if UnityMCP exists (handles both "UnityMCP" and legacy "unityMCP")
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
{
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
{
// UnityMCP is registered - now verify transport mode matches
// useHttpTransport parameter is required (non-nullable) to ensure thread safety
bool currentUseHttp = useHttpTransport;
// Get detailed info about the registration to check transport type
// Try both "UnityMCP" and "unityMCP" (legacy naming)
string getStdout = null, getStderr = null;
bool gotInfo = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend)
|| ExecPath.TryRun(claudePath, "mcp get unityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend);
if (gotInfo)
{
// Parse the output to determine registered transport mode
// The CLI output format contains "Type: http" or "Type: stdio"
bool registeredWithHttp = getStdout.Contains("Type: http", StringComparison.OrdinalIgnoreCase);
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
// Set the configured transport based on what we detected
// For HTTP, we can't distinguish local/remote from CLI output alone,
// so infer from the current scope setting when HTTP is detected.
if (registeredWithHttp)
{
client.configuredTransport = HttpEndpointUtility.IsRemoteScope()
? Models.ConfiguredTransport.HttpRemote
: Models.ConfiguredTransport.Http;
}
else if (registeredWithStdio)
{
client.configuredTransport = Models.ConfiguredTransport.Stdio;
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
// Check for transport mismatch (3-way: Stdio, Http, HttpRemote)
bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp);
// For stdio transport, also check package version
bool hasVersionMismatch = false;
string configuredPackageSource = null;
string expectedPackageSource = null;
if (registeredWithStdio)
{
expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource();
configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout);
hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) &&
!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase);
}
// If there's any mismatch and auto-rewrite is enabled, re-register
if (hasTransportMismatch || hasVersionMismatch)
{
// Configure() requires main thread (accesses EditorPrefs, Application.dataPath)
// Only attempt auto-rewrite if we're on the main thread
bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1;
if (attemptAutoRewrite && isMainThread)
{
string reason = hasTransportMismatch
? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})"
: $"Package version mismatch (registered: {configuredPackageSource}, expected: {expectedPackageSource})";
McpLog.Info($"{reason}. Re-registering...");
try
{
// Force re-register by ensuring status is not Configured (which would toggle to Unregister)
client.SetStatus(McpStatus.IncorrectPath);
Configure();
return client.status;
}
catch (Exception ex)
{
McpLog.Warn($"Auto-reregister failed: {ex.Message}");
client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register.");
return client.status;
}
}
else
{
if (hasTransportMismatch)
{
string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register.";
client.SetStatus(McpStatus.Error, errorMsg);
McpLog.Warn(errorMsg);
}
else
{
client.SetStatus(McpStatus.IncorrectPath, $"Package version mismatch: registered with '{configuredPackageSource}' but current version is '{expectedPackageSource}'.");
}
return client.status;
}
}
}
client.SetStatus(McpStatus.Configured);
return client.status;
}
}
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
catch (Exception ex)
{
client.SetStatus(McpStatus.Error, ex.Message);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
return client.status;
}
public override void Configure()
{
if (client.status == McpStatus.Configured)
{
Unregister();
}
else
{
Register();
}
}
/// <summary>
/// Thread-safe version of Configure that uses pre-captured main-thread values.
/// All parameters must be captured on the main thread before calling this method.
/// </summary>
public void ConfigureWithCapturedValues(
string projectDir, string claudePath, string pathPrepend,
bool useHttpTransport, string httpUrl,
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh,
string apiKey,
Models.ConfiguredTransport serverTransport)
{
if (client.status == McpStatus.Configured)
{
UnregisterWithCapturedValues(projectDir, claudePath, pathPrepend);
}
else
{
RegisterWithCapturedValues(projectDir, claudePath, pathPrepend,
useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh,
apiKey, serverTransport);
}
}
/// <summary>
/// Thread-safe registration using pre-captured values.
/// </summary>
private void RegisterWithCapturedValues(
string projectDir, string claudePath, string pathPrepend,
bool useHttpTransport, string httpUrl,
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh,
string apiKey,
Models.ConfiguredTransport serverTransport)
{
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
string args;
if (useHttpTransport)
{
// Only include API key header for remote-hosted mode
if (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey))
{
string safeKey = SanitizeShellHeaderValue(apiKey);
args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\"";
}
else
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
}
}
else
{
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = shouldForceRefresh ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
}
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Now add the registration
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
{
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
}
McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport.");
client.SetStatus(McpStatus.Configured);
client.configuredTransport = serverTransport;
}
/// <summary>
/// Thread-safe unregistration using pre-captured values.
/// </summary>
private void UnregisterWithCapturedValues(string projectDir, string claudePath, string pathPrepend)
{
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
McpLog.Info("Removing all UnityMCP registrations...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
McpLog.Info("MCP server successfully unregistered from Claude Code.");
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
private void Register()
{
var pathService = MCPServiceLocator.Paths;
string claudePath = pathService.GetClaudeCliPath();
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
string args;
if (useHttpTransport)
{
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
// Only include API key header for remote-hosted mode
if (HttpEndpointUtility.IsRemoteScope())
{
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
if (!string.IsNullOrEmpty(apiKey))
{
string safeKey = SanitizeShellHeaderValue(apiKey);
args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\"";
}
else
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
}
}
else
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
}
}
else
{
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
}
string projectDir = Path.GetDirectoryName(Application.dataPath);
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
{
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
}
else if (Application.platform == RuntimePlatform.LinuxEditor)
{
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
}
try
{
string claudeDir = Path.GetDirectoryName(claudePath);
if (!string.IsNullOrEmpty(claudeDir))
{
pathPrepend = string.IsNullOrEmpty(pathPrepend)
? claudeDir
: $"{claudeDir}:{pathPrepend}";
}
}
catch { }
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Now add the registration with the current transport mode
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
{
throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}");
}
McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport.");
// Set status to Configured immediately after successful registration
// The UI will trigger an async verification check separately to avoid blocking
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}
private void Unregister()
{
var pathService = MCPServiceLocator.Paths;
string claudePath = pathService.GetClaudeCliPath();
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
string projectDir = Path.GetDirectoryName(Application.dataPath);
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
{
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
}
else if (Application.platform == RuntimePlatform.LinuxEditor)
{
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
}
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
McpLog.Info("Removing all UnityMCP registrations...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
McpLog.Info("MCP server successfully unregistered from Claude Code.");
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
public override string GetManualSnippet()
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
if (useHttpTransport)
{
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
// Only include API key header for remote-hosted mode
string headerArg = "";
if (HttpEndpointUtility.IsRemoteScope())
{
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : "";
}
return "# Register the MCP server with Claude Code:\n" +
$"claude mcp add --transport http UnityMCP {httpUrl}{headerArg}\n\n" +
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list";
}
if (string.IsNullOrEmpty(uvxPath))
{
return "# Error: Configuration not available - check paths in Advanced Settings";
}
string packageSource = AssetPathUtility.GetMcpServerPackageSource();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
return "# Register the MCP server with Claude Code:\n" +
$"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{packageSource}\" mcp-for-unity\n\n" +
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list";
}
public override IList<string> GetInstallationSteps() => new List<string>
{
"Ensure Claude CLI is installed",
"Use Register to add UnityMCP (or run claude mcp add UnityMCP)",
"Restart Claude Code"
};
/// <summary>
/// Sanitizes a value for safe inclusion inside a double-quoted shell argument.
/// Escapes characters that are special within double quotes (", \, `, $, !)
/// to prevent shell injection or argument splitting.
/// </summary>
private static string SanitizeShellHeaderValue(string value)
{
if (string.IsNullOrEmpty(value))
return value;
var sb = new System.Text.StringBuilder(value.Length);
foreach (char c in value)
{
switch (c)
{
case '"':
case '\\':
case '`':
case '$':
case '!':
sb.Append('\\');
sb.Append(c);
break;
default:
sb.Append(c);
break;
}
}
return sb.ToString();
}
/// <summary>
/// Extracts the package source (--from argument value) from claude mcp get output.
/// The output format includes args like: --from "mcpforunityserver==9.0.1"
/// </summary>
private static string ExtractPackageSourceFromCliOutput(string cliOutput)
{
if (string.IsNullOrEmpty(cliOutput))
return null;
// Look for --from followed by the package source
// The CLI output may have it quoted or unquoted
int fromIndex = cliOutput.IndexOf("--from", StringComparison.OrdinalIgnoreCase);
if (fromIndex < 0)
return null;
// Move past "--from" and any whitespace
int startIndex = fromIndex + 6;
while (startIndex < cliOutput.Length && char.IsWhiteSpace(cliOutput[startIndex]))
startIndex++;
if (startIndex >= cliOutput.Length)
return null;
// Check if value is quoted
char quoteChar = cliOutput[startIndex];
if (quoteChar == '"' || quoteChar == '\'')
{
startIndex++;
int endIndex = cliOutput.IndexOf(quoteChar, startIndex);
if (endIndex > startIndex)
return cliOutput.Substring(startIndex, endIndex - startIndex);
}
else
{
// Unquoted - read until whitespace or end of line
int endIndex = startIndex;
while (endIndex < cliOutput.Length && !char.IsWhiteSpace(cliOutput[endIndex]))
endIndex++;
if (endIndex > startIndex)
return cliOutput.Substring(startIndex, endIndex - startIndex);
}
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Clients
{
/// <summary>
/// Central registry that auto-discovers configurators via TypeCache.
/// </summary>
public static class McpClientRegistry
{
private static List<IMcpClientConfigurator> cached;
public static IReadOnlyList<IMcpClientConfigurator> All
{
get
{
if (cached == null)
{
cached = BuildRegistry();
}
return cached;
}
}
private static List<IMcpClientConfigurator> BuildRegistry()
{
var configurators = new List<IMcpClientConfigurator>();
foreach (var type in TypeCache.GetTypesDerivedFrom<IMcpClientConfigurator>())
{
if (type.IsAbstract || !type.IsClass || !type.IsPublic)
continue;
// Require a public parameterless constructor
if (type.GetConstructor(Type.EmptyTypes) == null)
continue;
try
{
if (Activator.CreateInstance(type) is IMcpClientConfigurator instance)
{
configurators.Add(instance);
}
}
catch (Exception ex)
{
McpLog.Warn($"UnityMCP: Failed to instantiate configurator {type.Name}: {ex.Message}");
}
}
// Alphabetical order by display name
configurators = configurators.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase).ToList();
return configurators;
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
namespace MCPForUnity.Editor.Constants
{
/// <summary>
/// Protocol-level constants for API key authentication.
/// </summary>
internal static class AuthConstants
{
internal const string ApiKeyHeader = "X-API-Key";
}
}

View File

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

View File

@@ -0,0 +1,66 @@
namespace MCPForUnity.Editor.Constants
{
/// <summary>
/// Centralized list of EditorPrefs keys used by the MCP for Unity package.
/// Keeping them in one place avoids typos and simplifies migrations.
/// </summary>
internal static class EditorPrefKeys
{
internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport";
internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote"
internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid";
internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort";
internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc";
internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash";
internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath";
internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken";
internal const string DebugLogs = "MCPForUnity.DebugLogs";
internal const string ValidationLevel = "MCPForUnity.ValidationLevel";
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
internal const string ResumeHttpAfterReload = "MCPForUnity.ResumeHttpAfterReload";
internal const string ResumeStdioAfterReload = "MCPForUnity.ResumeStdioAfterReload";
internal const string UvxPathOverride = "MCPForUnity.UvxPath";
internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath";
internal const string HttpBaseUrl = "MCPForUnity.HttpUrl";
internal const string HttpRemoteBaseUrl = "MCPForUnity.HttpRemoteUrl";
internal const string SessionId = "MCPForUnity.SessionId";
internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl";
internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride";
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";
internal const string UseBetaServer = "MCPForUnity.UseBetaServer";
internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp";
internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";
internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath";
internal const string PackageDeployLastTargetPath = "MCPForUnity.PackageDeploy.LastTargetPath";
internal const string PackageDeployLastSourcePath = "MCPForUnity.PackageDeploy.LastSourcePath";
internal const string ServerSrc = "MCPForUnity.ServerSrc";
internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer";
internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig";
internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled";
internal const string ToolEnabledPrefix = "MCPForUnity.ToolEnabled.";
internal const string ToolFoldoutStatePrefix = "MCPForUnity.ToolFoldout.";
internal const string ResourceEnabledPrefix = "MCPForUnity.ResourceEnabled.";
internal const string ResourceFoldoutStatePrefix = "MCPForUnity.ResourceFoldout.";
internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel";
internal const string SetupCompleted = "MCPForUnity.SetupCompleted";
internal const string SetupDismissed = "MCPForUnity.SetupDismissed";
internal const string CustomToolRegistrationEnabled = "MCPForUnity.CustomToolRegistrationEnabled";
internal const string LastUpdateCheck = "MCPForUnity.LastUpdateCheck";
internal const string LatestKnownVersion = "MCPForUnity.LatestKnownVersion";
internal const string LastAssetStoreUpdateCheck = "MCPForUnity.LastAssetStoreUpdateCheck";
internal const string LatestKnownAssetStoreVersion = "MCPForUnity.LatestKnownAssetStoreVersion";
internal const string LastStdIoUpgradeVersion = "MCPForUnity.LastStdIoUpgradeVersion";
internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled";
internal const string CustomerUuid = "MCPForUnity.CustomerUUID";
internal const string ApiKey = "MCPForUnity.ApiKey";
}
}

View File

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

View File

@@ -0,0 +1,14 @@
namespace MCPForUnity.Editor.Constants
{
/// <summary>
/// Constants for health check status values.
/// Used for coordinating health state between Connection and Advanced sections.
/// </summary>
public static class HealthStatus
{
public const string Unknown = "Unknown";
public const string Healthy = "Healthy";
public const string PingFailed = "Ping Failed";
public const string Unhealthy = "Unhealthy";
}
}

View File

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

View File

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

View File

@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Dependencies.Models;
using MCPForUnity.Editor.Dependencies.PlatformDetectors;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Dependencies
{
/// <summary>
/// Main orchestrator for dependency validation and management
/// </summary>
public static class DependencyManager
{
private static readonly List<IPlatformDetector> _detectors = new List<IPlatformDetector>
{
new WindowsPlatformDetector(),
new MacOSPlatformDetector(),
new LinuxPlatformDetector()
};
private static IPlatformDetector _currentDetector;
/// <summary>
/// Get the platform detector for the current operating system
/// </summary>
public static IPlatformDetector GetCurrentPlatformDetector()
{
if (_currentDetector == null)
{
_currentDetector = _detectors.FirstOrDefault(d => d.CanDetect);
if (_currentDetector == null)
{
throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}");
}
}
return _currentDetector;
}
/// <summary>
/// Perform a comprehensive dependency check
/// </summary>
public static DependencyCheckResult CheckAllDependencies()
{
var result = new DependencyCheckResult();
try
{
var detector = GetCurrentPlatformDetector();
McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false);
// Check Python
var pythonStatus = detector.DetectPython();
result.Dependencies.Add(pythonStatus);
// Check uv
var uvStatus = detector.DetectUv();
result.Dependencies.Add(uvStatus);
// Generate summary and recommendations
result.GenerateSummary();
GenerateRecommendations(result, detector);
McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false);
}
catch (Exception ex)
{
McpLog.Error($"Error during dependency check: {ex.Message}");
result.Summary = $"Dependency check failed: {ex.Message}";
result.IsSystemReady = false;
}
return result;
}
/// <summary>
/// Get installation recommendations for the current platform
/// </summary>
public static string GetInstallationRecommendations()
{
try
{
var detector = GetCurrentPlatformDetector();
return detector.GetInstallationRecommendations();
}
catch (Exception ex)
{
return $"Error getting installation recommendations: {ex.Message}";
}
}
/// <summary>
/// Get platform-specific installation URLs
/// </summary>
public static (string pythonUrl, string uvUrl) GetInstallationUrls()
{
try
{
var detector = GetCurrentPlatformDetector();
return (detector.GetPythonInstallUrl(), detector.GetUvInstallUrl());
}
catch
{
return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/");
}
}
private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector)
{
var missing = result.GetMissingDependencies();
if (missing.Count == 0)
{
result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity.");
return;
}
foreach (var dep in missing)
{
if (dep.Name == "Python")
{
result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}");
}
else if (dep.Name == "uv Package Manager")
{
result.RecommendedActions.Add($"Install uv package manager from: {detector.GetUvInstallUrl()}");
}
else if (dep.Name == "MCP Server")
{
result.RecommendedActions.Add("MCP Server will be installed automatically when needed.");
}
}
if (result.GetMissingRequired().Count > 0)
{
result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Local Setup Window) for guided installation.");
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace MCPForUnity.Editor.Dependencies.Models
{
/// <summary>
/// Result of a comprehensive dependency check
/// </summary>
[Serializable]
public class DependencyCheckResult
{
/// <summary>
/// List of all dependency statuses checked
/// </summary>
public List<DependencyStatus> Dependencies { get; set; }
/// <summary>
/// Overall system readiness for MCP operations
/// </summary>
public bool IsSystemReady { get; set; }
/// <summary>
/// Whether all required dependencies are available
/// </summary>
public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false;
/// <summary>
/// Whether any optional dependencies are missing
/// </summary>
public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false;
/// <summary>
/// Summary message about the dependency state
/// </summary>
public string Summary { get; set; }
/// <summary>
/// Recommended next steps for the user
/// </summary>
public List<string> RecommendedActions { get; set; }
/// <summary>
/// Timestamp when this check was performed
/// </summary>
public DateTime CheckedAt { get; set; }
public DependencyCheckResult()
{
Dependencies = new List<DependencyStatus>();
RecommendedActions = new List<string>();
CheckedAt = DateTime.UtcNow;
}
/// <summary>
/// Get dependencies by availability status
/// </summary>
public List<DependencyStatus> GetMissingDependencies()
{
return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List<DependencyStatus>();
}
/// <summary>
/// Get missing required dependencies
/// </summary>
public List<DependencyStatus> GetMissingRequired()
{
return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List<DependencyStatus>();
}
/// <summary>
/// Generate a user-friendly summary of the dependency state
/// </summary>
public void GenerateSummary()
{
var missing = GetMissingDependencies();
var missingRequired = GetMissingRequired();
if (missing.Count == 0)
{
Summary = "All dependencies are available and ready.";
IsSystemReady = true;
}
else if (missingRequired.Count == 0)
{
Summary = $"System is ready. {missing.Count} optional dependencies are missing.";
IsSystemReady = true;
}
else
{
Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing.";
IsSystemReady = false;
}
}
}
}

View File

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

View File

@@ -0,0 +1,65 @@
using System;
namespace MCPForUnity.Editor.Dependencies.Models
{
/// <summary>
/// Represents the status of a dependency check
/// </summary>
[Serializable]
public class DependencyStatus
{
/// <summary>
/// Name of the dependency being checked
/// </summary>
public string Name { get; set; }
/// <summary>
/// Whether the dependency is available and functional
/// </summary>
public bool IsAvailable { get; set; }
/// <summary>
/// Version information if available
/// </summary>
public string Version { get; set; }
/// <summary>
/// Path to the dependency executable/installation
/// </summary>
public string Path { get; set; }
/// <summary>
/// Additional details about the dependency status
/// </summary>
public string Details { get; set; }
/// <summary>
/// Error message if dependency check failed
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// Whether this dependency is required for basic functionality
/// </summary>
public bool IsRequired { get; set; }
/// <summary>
/// Suggested installation method or URL
/// </summary>
public string InstallationHint { get; set; }
public DependencyStatus(string name, bool isRequired = true)
{
Name = name;
IsRequired = isRequired;
IsAvailable = false;
}
public override string ToString()
{
var status = IsAvailable ? "✓" : "✗";
var version = !string.IsNullOrEmpty(Version) ? $" ({Version})" : "";
return $"{status} {Name}{version}";
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
using MCPForUnity.Editor.Dependencies.Models;
namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
{
/// <summary>
/// Interface for platform-specific dependency detection
/// </summary>
public interface IPlatformDetector
{
/// <summary>
/// Platform name this detector handles
/// </summary>
string PlatformName { get; }
/// <summary>
/// Whether this detector can run on the current platform
/// </summary>
bool CanDetect { get; }
/// <summary>
/// Detect Python installation on this platform
/// </summary>
DependencyStatus DetectPython();
/// <summary>
/// Detect uv package manager on this platform
/// </summary>
DependencyStatus DetectUv();
/// <summary>
/// Get platform-specific installation recommendations
/// </summary>
string GetInstallationRecommendations();
/// <summary>
/// Get platform-specific Python installation URL
/// </summary>
string GetPythonInstallUrl();
/// <summary>
/// Get platform-specific uv installation URL
/// </summary>
string GetUvInstallUrl();
}
}

View File

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

View File

@@ -0,0 +1,207 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Dependencies.Models;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
{
/// <summary>
/// Linux-specific dependency detection
/// </summary>
public class LinuxPlatformDetector : PlatformDetectorBase
{
public override string PlatformName => "Linux";
public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
public override DependencyStatus DetectPython()
{
var status = new DependencyStatus("Python", isRequired: true)
{
InstallationHint = GetPythonInstallUrl()
};
try
{
// Try running python directly first
if (TryValidatePython("python3", out string version, out string fullPath) ||
TryValidatePython("python", out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found Python {version} in PATH";
return status;
}
// Fallback: try 'which' command
if (TryFindInPath("python3", out string pathResult) ||
TryFindInPath("python", out pathResult))
{
if (TryValidatePython(pathResult, out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found Python {version} in PATH";
return status;
}
}
status.ErrorMessage = "Python not found in PATH";
status.Details = "Install Python 3.10+ and ensure it's added to PATH.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting Python: {ex.Message}";
}
return status;
}
public override string GetPythonInstallUrl()
{
return "https://www.python.org/downloads/source/";
}
public override string GetUvInstallUrl()
{
return "https://docs.astral.sh/uv/getting-started/installation/#linux";
}
public override string GetInstallationRecommendations()
{
return @"Linux Installation Recommendations:
1. Python: Install via package manager or pyenv
- Ubuntu/Debian: sudo apt install python3 python3-pip
- Fedora/RHEL: sudo dnf install python3 python3-pip
- Arch: sudo pacman -S python python-pip
- Or use pyenv: https://github.com/pyenv/pyenv
2. uv Package Manager: Install via curl
- Run: curl -LsSf https://astral.sh/uv/install.sh | sh
- Or download from: https://github.com/astral-sh/uv/releases
3. MCP Server: Will be installed automatically by MCP for Unity
Note: Make sure ~/.local/bin is in your PATH for user-local installations.";
}
public override DependencyStatus DetectUv()
{
// First, honor overrides and cross-platform resolution via the base implementation
var status = base.DetectUv();
if (status.IsAvailable)
{
return status;
}
// If the user configured an override path but fallback was not used, keep the base result
// (failure typically means the override path is invalid and no system fallback found)
if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback)
{
return status;
}
try
{
string augmentedPath = BuildAugmentedPath();
// Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling
if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) ||
TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found uv {version} in PATH";
status.ErrorMessage = null;
return status;
}
status.ErrorMessage = "uv not found in PATH";
status.Details = "Install uv package manager and ensure it's added to PATH.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting uv: {ex.Message}";
}
return status;
}
private bool TryValidatePython(string pythonPath, out string version, out string fullPath)
{
version = null;
fullPath = null;
try
{
string augmentedPath = BuildAugmentedPath();
// First, try to resolve the absolute path for better UI/logging display
string commandToRun = pythonPath;
if (TryFindInPath(pythonPath, out string resolvedPath))
{
commandToRun = resolvedPath;
}
if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr,
5000, augmentedPath))
return false;
// Check stdout first, then stderr (some Python distributions output to stderr)
string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim();
if (output.StartsWith("Python "))
{
version = output.Substring(7);
fullPath = commandToRun;
if (TryParseVersion(version, out var major, out var minor))
{
return major > 3 || (major == 3 && minor >= 10);
}
}
}
catch
{
// Ignore validation errors
}
return false;
}
protected string BuildAugmentedPath()
{
var additions = GetPathAdditions();
if (additions.Length == 0) return null;
// Only return the additions - ExecPath.TryRun will prepend to existing PATH
return string.Join(Path.PathSeparator, additions);
}
private string[] GetPathAdditions()
{
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return new[]
{
"/usr/local/bin",
"/usr/bin",
"/bin",
"/snap/bin",
Path.Combine(homeDir, ".local", "bin")
};
}
protected override bool TryFindInPath(string executable, out string fullPath)
{
fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath());
return !string.IsNullOrEmpty(fullPath);
}
}
}

View File

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

View File

@@ -0,0 +1,205 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Dependencies.Models;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
{
/// <summary>
/// macOS-specific dependency detection
/// </summary>
public class MacOSPlatformDetector : PlatformDetectorBase
{
public override string PlatformName => "macOS";
public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public override DependencyStatus DetectPython()
{
var status = new DependencyStatus("Python", isRequired: true)
{
InstallationHint = GetPythonInstallUrl()
};
try
{
// 1. Try 'which' command with augmented PATH (prioritizing Homebrew)
if (TryFindInPath("python3", out string pathResult) ||
TryFindInPath("python", out pathResult))
{
if (TryValidatePython(pathResult, out string version, out string fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found Python {version} at {fullPath}";
return status;
}
}
// 2. Fallback: Try running python directly from PATH
if (TryValidatePython("python3", out string v, out string p) ||
TryValidatePython("python", out v, out p))
{
status.IsAvailable = true;
status.Version = v;
status.Path = p;
status.Details = $"Found Python {v} in PATH";
return status;
}
status.ErrorMessage = "Python not found in PATH or standard locations";
status.Details = "Install Python 3.10+ via Homebrew ('brew install python3') and ensure it's in your PATH.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting Python: {ex.Message}";
}
return status;
}
public override string GetPythonInstallUrl()
{
return "https://www.python.org/downloads/macos/";
}
public override string GetUvInstallUrl()
{
return "https://docs.astral.sh/uv/getting-started/installation/#macos";
}
public override string GetInstallationRecommendations()
{
return @"macOS Installation Recommendations:
1. Python: Install via Homebrew (recommended) or python.org
- Homebrew: brew install python3
- Direct download: https://python.org/downloads/macos/
2. uv Package Manager: Install via curl or Homebrew
- Curl: curl -LsSf https://astral.sh/uv/install.sh | sh
- Homebrew: brew install uv
3. MCP Server: Will be installed automatically by MCP for Unity Bridge
Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH.";
}
public override DependencyStatus DetectUv()
{
// First, honor overrides and cross-platform resolution via the base implementation
var status = base.DetectUv();
if (status.IsAvailable)
{
return status;
}
// If the user configured an override path but fallback was not used, keep the base result
// (failure typically means the override path is invalid and no system fallback found)
if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback)
{
return status;
}
try
{
string augmentedPath = BuildAugmentedPath();
// Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling
if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) ||
TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found uv {version} in PATH";
status.ErrorMessage = null;
return status;
}
status.ErrorMessage = "uv not found in PATH";
status.Details = "Install uv package manager and ensure it's added to PATH.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting uv: {ex.Message}";
}
return status;
}
private bool TryValidatePython(string pythonPath, out string version, out string fullPath)
{
version = null;
fullPath = null;
try
{
string augmentedPath = BuildAugmentedPath();
// First, try to resolve the absolute path for better UI/logging display
string commandToRun = pythonPath;
if (TryFindInPath(pythonPath, out string resolvedPath))
{
commandToRun = resolvedPath;
}
if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr,
5000, augmentedPath))
return false;
// Check stdout first, then stderr (some Python distributions output to stderr)
string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim();
if (output.StartsWith("Python "))
{
version = output.Substring(7);
fullPath = commandToRun;
if (TryParseVersion(version, out var major, out var minor))
{
return major > 3 || (major == 3 && minor >= 10);
}
}
}
catch
{
// Ignore validation errors
}
return false;
}
protected string BuildAugmentedPath()
{
var additions = GetPathAdditions();
if (additions.Length == 0) return null;
// Only return the additions - ExecPath.TryRun will prepend to existing PATH
return string.Join(Path.PathSeparator, additions);
}
private string[] GetPathAdditions()
{
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return new[]
{
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
Path.Combine(homeDir, ".local", "bin")
};
}
protected override bool TryFindInPath(string executable, out string fullPath)
{
fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath());
return !string.IsNullOrEmpty(fullPath);
}
}
}

View File

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

View File

@@ -0,0 +1,137 @@
using System;
using MCPForUnity.Editor.Dependencies.Models;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
{
/// <summary>
/// Base class for platform-specific dependency detection
/// </summary>
public abstract class PlatformDetectorBase : IPlatformDetector
{
public abstract string PlatformName { get; }
public abstract bool CanDetect { get; }
public abstract DependencyStatus DetectPython();
public abstract string GetPythonInstallUrl();
public abstract string GetUvInstallUrl();
public abstract string GetInstallationRecommendations();
public virtual DependencyStatus DetectUv()
{
var status = new DependencyStatus("uv Package Manager", isRequired: true)
{
InstallationHint = GetUvInstallUrl()
};
try
{
// Get uv path from PathResolverService (respects override)
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
// Verify uv executable and get version
if (MCPServiceLocator.Paths.TryValidateUvxExecutable(uvxPath, out string version))
{
status.IsAvailable = true;
status.Version = version;
status.Path = uvxPath;
// Check if we used fallback from override to system path
if (MCPServiceLocator.Paths.HasUvxPathFallback)
{
status.Details = $"Found uv {version} (fallback to system path)";
status.ErrorMessage = "Override path not found, using system path";
}
else
{
status.Details = MCPServiceLocator.Paths.HasUvxPathOverride
? $"Found uv {version} (override path)"
: $"Found uv {version} in system path";
}
return status;
}
status.ErrorMessage = "uvx not found";
status.Details = "Install uv package manager or configure path override in Advanced Settings.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting uvx: {ex.Message}";
}
return status;
}
protected bool TryParseVersion(string version, out int major, out int minor)
{
major = 0;
minor = 0;
try
{
var parts = version.Split('.');
if (parts.Length >= 2)
{
return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor);
}
}
catch
{
// Ignore parsing errors
}
return false;
}
// In PlatformDetectorBase.cs
protected bool TryValidateUvWithPath(string command, string augmentedPath, out string version, out string fullPath)
{
version = null;
fullPath = null;
try
{
string commandToRun = command;
if (TryFindInPath(command, out string resolvedPath))
{
commandToRun = resolvedPath;
}
if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr,
5000, augmentedPath))
return false;
string output = string.IsNullOrWhiteSpace(stdout) ? stderr.Trim() : stdout.Trim();
if (output.StartsWith("uvx ") || output.StartsWith("uv "))
{
int spaceIndex = output.IndexOf(' ');
if (spaceIndex >= 0)
{
var remainder = output.Substring(spaceIndex + 1).Trim();
int nextSpace = remainder.IndexOf(' ');
int parenIndex = remainder.IndexOf('(');
int endIndex = Math.Min(
nextSpace >= 0 ? nextSpace : int.MaxValue,
parenIndex >= 0 ? parenIndex : int.MaxValue
);
version = endIndex < int.MaxValue ? remainder.Substring(0, endIndex).Trim() : remainder;
fullPath = commandToRun;
return true;
}
}
}
catch
{
// Ignore validation errors
}
return false;
}
// Add abstract method for subclasses to implement
protected abstract bool TryFindInPath(string executable, out string fullPath);
}
}

View File

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

View File

@@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Dependencies.Models;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
{
/// <summary>
/// Windows-specific dependency detection
/// </summary>
public class WindowsPlatformDetector : PlatformDetectorBase
{
public override string PlatformName => "Windows";
public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public override DependencyStatus DetectPython()
{
var status = new DependencyStatus("Python", isRequired: true)
{
InstallationHint = GetPythonInstallUrl()
};
try
{
// Try running python directly first (works with Windows App Execution Aliases)
if (TryValidatePython("python3.exe", out string version, out string fullPath) ||
TryValidatePython("python.exe", out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found Python {version} in PATH";
return status;
}
// Fallback: try 'where' command
if (TryFindInPath("python3.exe", out string pathResult) ||
TryFindInPath("python.exe", out pathResult))
{
if (TryValidatePython(pathResult, out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found Python {version} in PATH";
return status;
}
}
// Fallback: try to find python via uv
if (TryFindPythonViaUv(out version, out fullPath))
{
status.IsAvailable = true;
status.Version = version;
status.Path = fullPath;
status.Details = $"Found Python {version} via uv";
return status;
}
status.ErrorMessage = "Python not found in PATH";
status.Details = "Install Python 3.10+ and ensure it's added to PATH.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting Python: {ex.Message}";
}
return status;
}
public override string GetPythonInstallUrl()
{
return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP";
}
public override string GetUvInstallUrl()
{
return "https://docs.astral.sh/uv/getting-started/installation/#windows";
}
public override string GetInstallationRecommendations()
{
return @"Windows Installation Recommendations:
1. Python: Install from Microsoft Store or python.org
- Microsoft Store: Search for 'Python 3.10' or higher
- Direct download: https://python.org/downloads/windows/
2. uv Package Manager: Install via PowerShell
- Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex""
- Or download from: https://github.com/astral-sh/uv/releases
3. MCP Server: Will be installed automatically by MCP for Unity Bridge";
}
public override DependencyStatus DetectUv()
{
// First, honor overrides and cross-platform resolution via the base implementation
var status = base.DetectUv();
if (status.IsAvailable)
{
return status;
}
// If the user configured an override path but fallback was not used, keep the base result
// (failure typically means the override path is invalid and no system fallback found)
if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback)
{
return status;
}
try
{
string augmentedPath = BuildAugmentedPath();
// try to find uv
if (TryValidateUvWithPath("uv.exe", augmentedPath, out string uvVersion, out string uvPath))
{
status.IsAvailable = true;
status.Version = uvVersion;
status.Path = uvPath;
status.Details = $"Found uv {uvVersion} at {uvPath}";
return status;
}
// try to find uvx
if (TryValidateUvWithPath("uvx.exe", augmentedPath, out string uvxVersion, out string uvxPath))
{
status.IsAvailable = true;
status.Version = uvxVersion;
status.Path = uvxPath;
status.Details = $"Found uvx {uvxVersion} at {uvxPath} (fallback)";
return status;
}
status.ErrorMessage = "uv not found in PATH";
status.Details = "Install uv package manager and ensure it's added to PATH.";
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting uv: {ex.Message}";
}
return status;
}
private bool TryFindPythonViaUv(out string version, out string fullPath)
{
version = null;
fullPath = null;
try
{
string augmentedPath = BuildAugmentedPath();
// Try to list installed python versions via uvx
if (!ExecPath.TryRun("uv", "python list", null, out string stdout, out string stderr, 5000, augmentedPath))
return false;
var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Contains("<download available>")) continue;
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
string potentialPath = parts[parts.Length - 1];
if (File.Exists(potentialPath) &&
(potentialPath.EndsWith("python.exe") || potentialPath.EndsWith("python3.exe")))
{
if (TryValidatePython(potentialPath, out version, out fullPath))
{
return true;
}
}
}
}
}
catch
{
// Ignore errors if uv is not installed or fails
}
return false;
}
private bool TryValidatePython(string pythonPath, out string version, out string fullPath)
{
version = null;
fullPath = null;
try
{
string augmentedPath = BuildAugmentedPath();
// First, try to resolve the absolute path for better UI/logging display
string commandToRun = pythonPath;
if (TryFindInPath(pythonPath, out string resolvedPath))
{
commandToRun = resolvedPath;
}
// Run 'python --version' to get the version
if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, 5000, augmentedPath))
return false;
// Check stdout first, then stderr (some Python distributions output to stderr)
string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim();
if (output.StartsWith("Python "))
{
version = output.Substring(7);
fullPath = commandToRun;
if (TryParseVersion(version, out var major, out var minor))
{
return major > 3 || (major == 3 && minor >= 10);
}
}
}
catch
{
// Ignore validation errors
}
return false;
}
protected override bool TryFindInPath(string executable, out string fullPath)
{
fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath());
return !string.IsNullOrEmpty(fullPath);
}
protected string BuildAugmentedPath()
{
var additions = GetPathAdditions();
if (additions.Length == 0) return null;
// Only return the additions - ExecPath.TryRun will prepend to existing PATH
return string.Join(Path.PathSeparator, additions);
}
private string[] GetPathAdditions()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var additions = new List<string>();
// uv common installation paths
if (!string.IsNullOrEmpty(localAppData))
additions.Add(Path.Combine(localAppData, "Programs", "uv"));
if (!string.IsNullOrEmpty(programFiles))
additions.Add(Path.Combine(programFiles, "uv"));
// npm global paths
if (!string.IsNullOrEmpty(appData))
additions.Add(Path.Combine(appData, "npm"));
if (!string.IsNullOrEmpty(localAppData))
additions.Add(Path.Combine(localAppData, "npm"));
// Python common paths
if (!string.IsNullOrEmpty(localAppData))
additions.Add(Path.Combine(localAppData, "Programs", "Python"));
// Instead of hardcoded versions, enumerate existing directories
if (!string.IsNullOrEmpty(programFiles))
{
try
{
var pythonDirs = Directory.GetDirectories(programFiles, "Python3*")
.OrderByDescending(d => d); // Newest first
foreach (var dir in pythonDirs)
{
additions.Add(dir);
}
}
catch { /* Ignore if directory doesn't exist */ }
}
// User scripts
if (!string.IsNullOrEmpty(homeDir))
additions.Add(Path.Combine(homeDir, ".local", "bin"));
return additions.ToArray();
}
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,430 @@
using System;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity asset paths.
/// </summary>
public static class AssetPathUtility
{
/// <summary>
/// Normalizes path separators to forward slashes without modifying the path structure.
/// Use this for non-asset paths (e.g., file system paths, relative directories).
/// </summary>
public static string NormalizeSeparators(string path)
{
if (string.IsNullOrEmpty(path))
return path;
return path.Replace('\\', '/');
}
/// <summary>
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
/// Also protects against path traversal attacks using "../" sequences.
/// </summary>
public static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
path = NormalizeSeparators(path);
// Check for path traversal sequences
if (path.Contains(".."))
{
McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'");
return null;
}
// Ensure path starts with Assets/
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
/// <summary>
/// Checks if a given asset path is valid and safe (no traversal, within Assets folder).
/// </summary>
/// <returns>True if the path is valid, false otherwise.</returns>
public static bool IsValidAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
// Normalize for comparison
string normalized = NormalizeSeparators(path);
// Must start with Assets/
if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Must not contain traversal sequences
if (normalized.Contains(".."))
{
return false;
}
// Must not contain invalid path characters
char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' };
foreach (char c in invalidChars)
{
if (normalized.IndexOf(c) >= 0)
{
return false;
}
}
return true;
}
/// <summary>
/// Gets the MCP for Unity package root path.
/// Works for registry Package Manager, local Package Manager, and Asset Store installations.
/// </summary>
/// <returns>The package root path (virtual for PM, absolute for Asset Store), or null if not found</returns>
public static string GetMcpPackageRootPath()
{
try
{
// Try Package Manager first (registry and local installs)
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath))
{
return packageInfo.assetPath;
}
// Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity)
string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}");
if (guids.Length == 0)
{
McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase");
return null;
}
string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]);
// Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs
// Extract {packageRoot}
int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal);
if (editorIndex >= 0)
{
return scriptPath.Substring(0, editorIndex);
}
McpLog.Warn($"Could not determine package root from script path: {scriptPath}");
return null;
}
catch (Exception ex)
{
McpLog.Error($"Failed to get package root path: {ex.Message}");
return null;
}
}
/// <summary>
/// Reads and parses the package.json file for MCP for Unity.
/// Handles both Package Manager (registry/local) and Asset Store installations.
/// </summary>
/// <returns>JObject containing package.json data, or null if not found or parse failed</returns>
public static JObject GetPackageJson()
{
try
{
string packageRoot = GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return null;
}
string packageJsonPath = Path.Combine(packageRoot, "package.json");
// Convert virtual asset path to file system path
if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
{
// Package Manager install - must use PackageInfo.resolvedPath
// Virtual paths like "Packages/..." don't work with File.Exists()
// Registry packages live in Library/PackageCache/package@version/
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath))
{
packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json");
}
else
{
McpLog.Warn("Could not resolve Package Manager path for package.json");
return null;
}
}
else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
// Asset Store install - convert to absolute file system path
// Application.dataPath is the absolute path to the Assets folder
string relativePath = packageRoot.Substring("Assets/".Length);
packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json");
}
if (!File.Exists(packageJsonPath))
{
McpLog.Warn($"package.json not found at: {packageJsonPath}");
return null;
}
string json = File.ReadAllText(packageJsonPath);
return JObject.Parse(json);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read or parse package.json: {ex.Message}");
return null;
}
}
/// <summary>
/// Gets the package source for the MCP server (used with uvx --from).
/// Checks for EditorPrefs override first (supports git URLs, file:// paths, etc.),
/// then falls back to PyPI package reference.
/// </summary>
/// <returns>Package source string for uvx --from argument</returns>
public static string GetMcpServerPackageSource()
{
// Check for override first (supports git URLs, file:// paths, local paths)
string sourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(sourceOverride))
{
return sourceOverride;
}
// Default to PyPI package (avoids Windows long path issues with git clone)
string version = GetPackageVersion();
if (version == "unknown")
{
// Fall back to latest PyPI version so configs remain valid in test scenarios
return "mcpforunityserver";
}
return $"mcpforunityserver=={version}";
}
/// <summary>
/// Deprecated: Use GetMcpServerPackageSource() instead.
/// Kept for backwards compatibility.
/// </summary>
[System.Obsolete("Use GetMcpServerPackageSource() instead")]
public static string GetMcpServerGitUrl() => GetMcpServerPackageSource();
/// <summary>
/// Gets structured uvx command parts for different client configurations
/// </summary>
/// <returns>Tuple containing (uvxPath, fromUrl, packageName)</returns>
public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts()
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string fromUrl = GetMcpServerPackageSource();
string packageName = "mcp-for-unity";
return (uvxPath, fromUrl, packageName);
}
/// <summary>
/// Builds the uvx package source arguments for the MCP server.
/// Handles beta server mode (prerelease from PyPI) vs standard mode (pinned version or override).
/// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports.
/// Priority: explicit fromUrl override > beta server mode > default package.
/// </summary>
/// <param name="quoteFromPath">Whether to quote the --from path (needed for command-line strings, not for arg lists)</param>
/// <returns>The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0")</returns>
public static string GetBetaServerFromArgs(bool quoteFromPath = false)
{
// Explicit override (local path, git URL, etc.) always wins
string fromUrl = GetMcpServerPackageSource();
string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(overrideUrl))
{
return $"--from {fromUrl}";
}
// Beta server mode: use prerelease from PyPI
bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
if (useBetaServer)
{
// Use --prerelease explicit with version specifier to only get prereleases of our package,
// not of dependencies (which can be broken on PyPI).
string fromValue = quoteFromPath ? "\"mcpforunityserver>=0.0.0a0\"" : "mcpforunityserver>=0.0.0a0";
return $"--prerelease explicit --from {fromValue}";
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(fromUrl))
{
return $"--from {fromUrl}";
}
return string.Empty;
}
/// <summary>
/// Builds the uvx package source arguments as a list (for JSON config builders).
/// Priority: explicit fromUrl override > beta server mode > default package.
/// </summary>
/// <returns>List of arguments to add to uvx command</returns>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList()
{
var args = new System.Collections.Generic.List<string>();
// Explicit override (local path, git URL, etc.) always wins
string fromUrl = GetMcpServerPackageSource();
string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(overrideUrl))
{
args.Add("--from");
args.Add(fromUrl);
return args;
}
// Beta server mode: use prerelease from PyPI
bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
if (useBetaServer)
{
args.Add("--prerelease");
args.Add("explicit");
args.Add("--from");
args.Add("mcpforunityserver>=0.0.0a0");
return args;
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(fromUrl))
{
args.Add("--from");
args.Add(fromUrl);
}
return args;
}
/// <summary>
/// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
/// Note: --reinstall is not supported by uvx and will cause a warning.
/// </summary>
public static bool ShouldForceUvxRefresh()
{
bool devForceRefresh = false;
try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }
if (devForceRefresh)
return true;
// Auto-enable force refresh when using a local path override.
return IsLocalServerPath();
}
/// <summary>
/// Returns true if the server URL is a local path (file:// or absolute path).
/// </summary>
public static bool IsLocalServerPath()
{
string fromUrl = GetMcpServerPackageSource();
if (string.IsNullOrEmpty(fromUrl))
return false;
// Check for file:// protocol or absolute local path
return fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase) ||
System.IO.Path.IsPathRooted(fromUrl);
}
/// <summary>
/// Gets the local server path if GitUrlOverride points to a local directory.
/// Returns null if not using a local path.
/// </summary>
public static string GetLocalServerPath()
{
if (!IsLocalServerPath())
return null;
string fromUrl = GetMcpServerPackageSource();
if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
// Strip file:// prefix
fromUrl = fromUrl.Substring(7);
}
return fromUrl;
}
/// <summary>
/// Cleans stale Python build artifacts from the local server path.
/// This is necessary because Python's build system doesn't remove deleted files from build/,
/// and the auto-discovery mechanism will pick up old .py files causing ghost resources/tools.
/// </summary>
/// <returns>True if cleaning was performed, false if not applicable or failed.</returns>
public static bool CleanLocalServerBuildArtifacts()
{
string localPath = GetLocalServerPath();
if (string.IsNullOrEmpty(localPath))
return false;
// Clean the build/ directory which can contain stale .py files
string buildPath = System.IO.Path.Combine(localPath, "build");
if (System.IO.Directory.Exists(buildPath))
{
try
{
System.IO.Directory.Delete(buildPath, recursive: true);
McpLog.Info($"Cleaned stale build artifacts from: {buildPath}");
return true;
}
catch (Exception ex)
{
McpLog.Warn($"Failed to clean build artifacts: {ex.Message}");
return false;
}
}
return false;
}
/// <summary>
/// Gets the package version from package.json
/// </summary>
/// <returns>Version string, or "unknown" if not found</returns>
public static string GetPackageVersion()
{
try
{
var packageJson = GetPackageJson();
if (packageJson == null)
{
return "unknown";
}
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? "unknown" : version;
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get package version: {ex.Message}");
return "unknown";
}
}
}
}

View File

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

View File

@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.External.Tommy;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Codex CLI specific configuration helpers. Handles TOML snippet
/// generation and lightweight parsing so Codex can join the auto-setup
/// flow alongside JSON-based clients.
/// </summary>
public static class CodexConfigHelper
{
private static void AddDevModeArgs(TomlArray args)
{
if (args == null) return;
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
if (!AssetPathUtility.ShouldForceUvxRefresh()) return;
args.Add(new TomlString { Value = "--no-cache" });
args.Add(new TomlString { Value = "--refresh" });
}
public static string BuildCodexServerBlock(string uvPath)
{
var table = new TomlTable();
var mcpServers = new TomlTable();
var unityMCP = new TomlTable();
// Check transport preference
bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
if (useHttpTransport)
{
// HTTP mode: Use url field
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
unityMCP["url"] = new TomlString { Value = httpUrl };
// Enable Codex's Rust MCP client for HTTP/SSE transport
EnsureRmcpClientFeature(table);
}
else
{
// Stdio mode: Use command and args
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
unityMCP["command"] = uvxPath;
var args = new TomlArray();
AddDevModeArgs(args);
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(new TomlString { Value = arg });
}
args.Add(new TomlString { Value = packageName });
args.Add(new TomlString { Value = "--transport" });
args.Add(new TomlString { Value = "stdio" });
unityMCP["args"] = args;
// Add Windows-specific environment configuration for stdio mode
var platformService = MCPServiceLocator.Platform;
if (platformService.IsWindows())
{
var envTable = new TomlTable { IsInline = true };
envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
unityMCP["env"] = envTable;
}
// Allow extra time for uvx to download packages on first run
unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 };
}
mcpServers["unityMCP"] = unityMCP;
table["mcp_servers"] = mcpServers;
using var writer = new StringWriter();
table.WriteTo(writer);
return writer.ToString();
}
public static string UpsertCodexServerBlock(string existingToml, string uvPath)
{
// Parse existing TOML or create new root table
var root = TryParseToml(existingToml) ?? new TomlTable();
bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
// Ensure mcp_servers table exists
if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable))
{
root["mcp_servers"] = new TomlTable();
}
var mcpServers = root["mcp_servers"] as TomlTable;
// Create or update unityMCP table
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath);
if (useHttpTransport)
{
EnsureRmcpClientFeature(root);
}
// Serialize back to TOML
using var writer = new StringWriter();
root.WriteTo(writer);
return writer.ToString();
}
public static bool TryParseCodexServer(string toml, out string command, out string[] args)
{
return TryParseCodexServer(toml, out command, out args, out _);
}
public static bool TryParseCodexServer(string toml, out string command, out string[] args, out string url)
{
command = null;
args = null;
url = null;
var root = TryParseToml(toml);
if (root == null) return false;
if (!TryGetTable(root, "mcp_servers", out var servers)
&& !TryGetTable(root, "mcpServers", out servers))
{
return false;
}
if (!TryGetTable(servers, "unityMCP", out var unity))
{
return false;
}
// Check for HTTP mode (url field)
url = GetTomlString(unity, "url");
if (!string.IsNullOrEmpty(url))
{
// HTTP mode detected - return true with url
return true;
}
// Check for stdio mode (command + args)
command = GetTomlString(unity, "command");
args = GetTomlStringArray(unity, "args");
return !string.IsNullOrEmpty(command) && args != null;
}
/// <summary>
/// Safely parses TOML string, returning null on failure
/// </summary>
private static TomlTable TryParseToml(string toml)
{
if (string.IsNullOrWhiteSpace(toml)) return null;
try
{
using var reader = new StringReader(toml);
return TOML.Parse(reader);
}
catch (TomlParseException)
{
return null;
}
catch (TomlSyntaxException)
{
return null;
}
catch (FormatException)
{
return null;
}
}
/// <summary>
/// Creates a TomlTable for the unityMCP server configuration
/// </summary>
/// <param name="uvPath">Path to uv executable (used as fallback if uvx is not available)</param>
private static TomlTable CreateUnityMcpTable(string uvPath)
{
var unityMCP = new TomlTable();
// Check transport preference
bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
if (useHttpTransport)
{
// HTTP mode: Use url field
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
unityMCP["url"] = new TomlString { Value = httpUrl };
}
else
{
// Stdio mode: Use command and args
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
unityMCP["command"] = new TomlString { Value = uvxPath };
var argsArray = new TomlArray();
AddDevModeArgs(argsArray);
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
argsArray.Add(new TomlString { Value = arg });
}
argsArray.Add(new TomlString { Value = packageName });
argsArray.Add(new TomlString { Value = "--transport" });
argsArray.Add(new TomlString { Value = "stdio" });
unityMCP["args"] = argsArray;
// Add Windows-specific environment configuration for stdio mode
var platformService = MCPServiceLocator.Platform;
if (platformService.IsWindows())
{
var envTable = new TomlTable { IsInline = true };
envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
unityMCP["env"] = envTable;
}
// Allow extra time for uvx to download packages on first run
unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 };
}
return unityMCP;
}
/// <summary>
/// Ensures the features table contains the rmcp_client flag for HTTP/SSE transport.
/// </summary>
private static void EnsureRmcpClientFeature(TomlTable root)
{
if (root == null) return;
if (!root.TryGetNode("features", out var featuresNode) || featuresNode is not TomlTable features)
{
features = new TomlTable();
root["features"] = features;
}
features["rmcp_client"] = new TomlBoolean { Value = true };
}
private static bool TryGetTable(TomlTable parent, string key, out TomlTable table)
{
table = null;
if (parent == null) return false;
if (parent.TryGetNode(key, out var node))
{
if (node is TomlTable tbl)
{
table = tbl;
return true;
}
if (node is TomlArray array)
{
var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault();
if (firstTable != null)
{
table = firstTable;
return true;
}
}
}
return false;
}
private static string GetTomlString(TomlTable table, string key)
{
if (table != null && table.TryGetNode(key, out var node))
{
if (node is TomlString str) return str.Value;
if (node.HasValue) return node.ToString();
}
return null;
}
private static string[] GetTomlStringArray(TomlTable table, string key)
{
if (table == null) return null;
if (!table.TryGetNode(key, out var node)) return null;
if (node is TomlArray array)
{
List<string> values = new List<string>();
foreach (TomlNode element in array.Children)
{
if (element is TomlString str)
{
values.Add(str.Value);
}
else if (element.HasValue)
{
values.Add(element.ToString());
}
}
return values.Count > 0 ? values.ToArray() : Array.Empty<string>();
}
if (node is TomlString single)
{
return new[] { single.Value };
}
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Low-level component operations extracted from ManageGameObject and ManageComponents.
/// Provides pure C# operations without JSON parsing or response formatting.
/// </summary>
public static class ComponentOps
{
/// <summary>
/// Adds a component to a GameObject with Undo support.
/// </summary>
/// <param name="target">The target GameObject</param>
/// <param name="componentType">The type of component to add</param>
/// <param name="error">Error message if operation fails</param>
/// <returns>The added component, or null if failed</returns>
public static Component AddComponent(GameObject target, Type componentType, out string error)
{
error = null;
if (target == null)
{
error = "Target GameObject is null.";
return null;
}
if (componentType == null || !typeof(Component).IsAssignableFrom(componentType))
{
error = $"Type '{componentType?.Name ?? "null"}' is not a valid Component type.";
return null;
}
// Prevent adding duplicate Transform
if (componentType == typeof(Transform))
{
error = "Cannot add another Transform component.";
return null;
}
// Check for 2D/3D physics conflicts
string conflictError = CheckPhysicsConflict(target, componentType);
if (conflictError != null)
{
error = conflictError;
return null;
}
try
{
Component newComponent = Undo.AddComponent(target, componentType);
if (newComponent == null)
{
error = $"Failed to add component '{componentType.Name}' to '{target.name}'. It might be disallowed.";
return null;
}
// Apply default values for specific component types
ApplyDefaultValues(newComponent);
return newComponent;
}
catch (Exception ex)
{
error = $"Error adding component '{componentType.Name}': {ex.Message}";
return null;
}
}
/// <summary>
/// Removes a component from a GameObject with Undo support.
/// </summary>
/// <param name="target">The target GameObject</param>
/// <param name="componentType">The type of component to remove</param>
/// <param name="error">Error message if operation fails</param>
/// <returns>True if component was removed successfully</returns>
public static bool RemoveComponent(GameObject target, Type componentType, out string error)
{
error = null;
if (target == null)
{
error = "Target GameObject is null.";
return false;
}
if (componentType == null)
{
error = "Component type is null.";
return false;
}
// Prevent removing Transform
if (componentType == typeof(Transform))
{
error = "Cannot remove Transform component.";
return false;
}
Component component = target.GetComponent(componentType);
if (component == null)
{
error = $"Component '{componentType.Name}' not found on '{target.name}'.";
return false;
}
try
{
Undo.DestroyObjectImmediate(component);
return true;
}
catch (Exception ex)
{
error = $"Error removing component '{componentType.Name}': {ex.Message}";
return false;
}
}
/// <summary>
/// Sets a property value on a component using reflection.
/// </summary>
/// <param name="component">The target component</param>
/// <param name="propertyName">The property or field name</param>
/// <param name="value">The value to set (JToken)</param>
/// <param name="error">Error message if operation fails</param>
/// <returns>True if property was set successfully</returns>
public static bool SetProperty(Component component, string propertyName, JToken value, out string error)
{
error = null;
if (component == null)
{
error = "Component is null.";
return false;
}
if (string.IsNullOrEmpty(propertyName))
{
error = "Property name is null or empty.";
return false;
}
Type type = component.GetType();
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
string normalizedName = ParamCoercion.NormalizePropertyName(propertyName);
// Try property first - check both original and normalized names for backwards compatibility
PropertyInfo propInfo = type.GetProperty(propertyName, flags)
?? type.GetProperty(normalizedName, flags);
if (propInfo != null && propInfo.CanWrite)
{
try
{
object convertedValue = PropertyConversion.ConvertToType(value, propInfo.PropertyType);
// Detect conversion failure: null result when input wasn't null
if (convertedValue == null && value.Type != JTokenType.Null)
{
error = $"Failed to convert value for property '{propertyName}' to type '{propInfo.PropertyType.Name}'.";
return false;
}
propInfo.SetValue(component, convertedValue);
return true;
}
catch (Exception ex)
{
error = $"Failed to set property '{propertyName}': {ex.Message}";
return false;
}
}
// Try field - check both original and normalized names for backwards compatibility
FieldInfo fieldInfo = type.GetField(propertyName, flags)
?? type.GetField(normalizedName, flags);
if (fieldInfo != null && !fieldInfo.IsInitOnly)
{
try
{
object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType);
// Detect conversion failure: null result when input wasn't null
if (convertedValue == null && value.Type != JTokenType.Null)
{
error = $"Failed to convert value for field '{propertyName}' to type '{fieldInfo.FieldType.Name}'.";
return false;
}
fieldInfo.SetValue(component, convertedValue);
return true;
}
catch (Exception ex)
{
error = $"Failed to set field '{propertyName}': {ex.Message}";
return false;
}
}
// Try non-public serialized fields - traverse inheritance hierarchy
// Type.GetField() with NonPublic only finds fields declared directly on that type,
// so we need to walk up the inheritance chain manually
fieldInfo = FindSerializedFieldInHierarchy(type, propertyName)
?? FindSerializedFieldInHierarchy(type, normalizedName);
if (fieldInfo != null)
{
try
{
object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType);
// Detect conversion failure: null result when input wasn't null
if (convertedValue == null && value.Type != JTokenType.Null)
{
error = $"Failed to convert value for serialized field '{propertyName}' to type '{fieldInfo.FieldType.Name}'.";
return false;
}
fieldInfo.SetValue(component, convertedValue);
return true;
}
catch (Exception ex)
{
error = $"Failed to set serialized field '{propertyName}': {ex.Message}";
return false;
}
}
error = $"Property or field '{propertyName}' not found on component '{type.Name}'.";
return false;
}
/// <summary>
/// Gets all public properties and fields from a component type.
/// </summary>
public static List<string> GetAccessibleMembers(Type componentType)
{
var members = new List<string>();
if (componentType == null) return members;
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
foreach (var prop in componentType.GetProperties(flags))
{
if (prop.CanWrite && prop.GetSetMethod() != null)
{
members.Add(prop.Name);
}
}
foreach (var field in componentType.GetFields(flags))
{
if (!field.IsInitOnly)
{
members.Add(field.Name);
}
}
// Include private [SerializeField] fields - traverse inheritance hierarchy
// Type.GetFields with NonPublic only returns fields declared directly on that type,
// so we need to walk up the chain to find inherited private serialized fields
var seenFieldNames = new HashSet<string>(members); // Avoid duplicates with public fields
Type currentType = componentType;
while (currentType != null && currentType != typeof(object))
{
foreach (var field in currentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (field.GetCustomAttribute<SerializeField>() != null && !seenFieldNames.Contains(field.Name))
{
members.Add(field.Name);
seenFieldNames.Add(field.Name);
}
}
currentType = currentType.BaseType;
}
members.Sort();
return members;
}
// --- Private Helpers ---
/// <summary>
/// Searches for a non-public [SerializeField] field through the entire inheritance hierarchy.
/// Type.GetField() with NonPublic only returns fields declared directly on that type,
/// so this method walks up the chain to find inherited private serialized fields.
/// </summary>
private static FieldInfo FindSerializedFieldInHierarchy(Type type, string fieldName)
{
if (type == null || string.IsNullOrEmpty(fieldName))
return null;
BindingFlags privateFlags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
Type currentType = type;
// Walk up the inheritance chain
while (currentType != null && currentType != typeof(object))
{
// Search for the field on this specific type (case-insensitive)
foreach (var field in currentType.GetFields(privateFlags))
{
if (string.Equals(field.Name, fieldName, StringComparison.OrdinalIgnoreCase) &&
field.GetCustomAttribute<SerializeField>() != null)
{
return field;
}
}
currentType = currentType.BaseType;
}
return null;
}
private static string CheckPhysicsConflict(GameObject target, Type componentType)
{
bool isAdding2DPhysics =
typeof(Rigidbody2D).IsAssignableFrom(componentType) ||
typeof(Collider2D).IsAssignableFrom(componentType);
bool isAdding3DPhysics =
typeof(Rigidbody).IsAssignableFrom(componentType) ||
typeof(Collider).IsAssignableFrom(componentType);
if (isAdding2DPhysics)
{
if (target.GetComponent<Rigidbody>() != null || target.GetComponent<Collider>() != null)
{
return $"Cannot add 2D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 3D Rigidbody or Collider.";
}
}
else if (isAdding3DPhysics)
{
if (target.GetComponent<Rigidbody2D>() != null || target.GetComponent<Collider2D>() != null)
{
return $"Cannot add 3D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 2D Rigidbody or Collider.";
}
}
return null;
}
private static void ApplyDefaultValues(Component component)
{
// Default newly added Lights to Directional
if (component is Light light)
{
light.type = LightType.Directional;
}
}
}
}

View File

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

View File

@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
public static class ConfigJsonBuilder
{
public static string BuildManualConfigJson(string uvPath, McpClient client)
{
var root = new JObject();
bool isVSCode = client?.IsVsCodeLayout == true;
JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers");
var unity = new JObject();
PopulateUnityNode(unity, uvPath, client, isVSCode);
container["unityMCP"] = unity;
return root.ToString(Formatting.Indented);
}
public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client)
{
if (root == null) root = new JObject();
bool isVSCode = client?.IsVsCodeLayout == true;
JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers");
JObject unity = container["unityMCP"] as JObject ?? new JObject();
PopulateUnityNode(unity, uvPath, client, isVSCode);
container["unityMCP"] = unity;
return root;
}
/// <summary>
/// Centralized builder that applies all caveats consistently.
/// - Sets command/args with uvx and package version
/// - Ensures env exists
/// - Adds transport configuration (HTTP or stdio)
/// - Adds disabled:false for Windsurf/Kiro only when missing
/// </summary>
private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode)
{
// Get transport preference (default to HTTP)
bool prefValue = EditorConfigurationCache.Instance.UseHttpTransport;
bool clientSupportsHttp = client?.SupportsHttpTransport != false;
bool useHttpTransport = clientSupportsHttp && prefValue;
string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty;
var urlPropsToRemove = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" };
urlPropsToRemove.Remove(httpProperty);
if (useHttpTransport)
{
// HTTP mode: Use URL, no command
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
unity[httpProperty] = httpUrl;
foreach (var prop in urlPropsToRemove)
{
if (unity[prop] != null) unity.Remove(prop);
}
// Remove command/args if they exist from previous config
if (unity["command"] != null) unity.Remove("command");
if (unity["args"] != null) unity.Remove("args");
// Only include API key header for remote-hosted mode
if (HttpEndpointUtility.IsRemoteScope())
{
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
if (!string.IsNullOrEmpty(apiKey))
{
var headers = new JObject { [AuthConstants.ApiKeyHeader] = apiKey };
unity["headers"] = headers;
}
else
{
if (unity["headers"] != null) unity.Remove("headers");
}
}
else
{
// Local HTTP doesn't use API keys; remove any stale headers
if (unity["headers"] != null) unity.Remove("headers");
}
if (isVSCode)
{
unity["type"] = "http";
}
// Also add type for Claude Code (uses mcpServers layout but needs type field)
else if (client?.name == "Claude Code")
{
unity["type"] = "http";
}
}
else
{
// Stdio mode: Use uvx command
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
var toolArgs = BuildUvxArgs(fromUrl, packageName);
unity["command"] = uvxPath;
unity["args"] = JArray.FromObject(toolArgs.ToArray());
// Remove url/serverUrl if they exist from previous config
if (unity["url"] != null) unity.Remove("url");
if (unity["serverUrl"] != null) unity.Remove("serverUrl");
if (isVSCode)
{
unity["type"] = "stdio";
}
}
// Remove type for non-VSCode clients (except Claude Code which needs it)
if (!isVSCode && client?.name != "Claude Code" && unity["type"] != null)
{
unity.Remove("type");
}
bool requiresEnv = client?.EnsureEnvObject == true;
bool stripEnv = client?.StripEnvWhenNotRequired == true;
if (requiresEnv)
{
if (unity["env"] == null)
{
unity["env"] = new JObject();
}
}
else if (stripEnv && unity["env"] != null)
{
unity.Remove("env");
}
if (client?.DefaultUnityFields != null)
{
foreach (var kvp in client.DefaultUnityFields)
{
if (unity[kvp.Key] == null)
{
unity[kvp.Key] = kvp.Value != null ? JToken.FromObject(kvp.Value) : JValue.CreateNull();
}
}
}
}
private static JObject EnsureObject(JObject parent, string name)
{
if (parent[name] is JObject o) return o;
var created = new JObject();
parent[name] = created;
return created;
}
private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
{
// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
// `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated.
// Note: --reinstall is not supported by uvx and will cause a warning.
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
var args = new List<string>();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
if (AssetPathUtility.ShouldForceUvxRefresh())
{
args.Add("--no-cache");
args.Add("--refresh");
}
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add(arg);
}
args.Add(packageName);
args.Add("--transport");
args.Add("stdio");
return args;
}
}
}

View File

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

View File

@@ -0,0 +1,324 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
internal static class ExecPath
{
private const string PrefClaude = EditorPrefKeys.ClaudeCliPathOverride;
// Resolve Claude CLI absolute path. Pref → env → common locations → PATH.
internal static string ResolveClaude()
{
try
{
string pref = EditorPrefs.GetString(PrefClaude, string.Empty);
if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref;
}
catch { }
string env = Environment.GetEnvironmentVariable("CLAUDE_CLI");
if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
Path.Combine(home, ".local", "bin", "claude"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
#else
return null;
#endif
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
#if UNITY_EDITOR_WIN
// Common npm global locations
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
string[] candidates =
{
// Prefer .cmd (most reliable from non-interactive processes)
Path.Combine(appData, "npm", "claude.cmd"),
Path.Combine(localAppData, "npm", "claude.cmd"),
// Fall back to PowerShell shim if only .ps1 is present
Path.Combine(appData, "npm", "claude.ps1"),
Path.Combine(localAppData, "npm", "claude.ps1"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
string fromWhere = FindInPathWindows("claude.exe") ?? FindInPathWindows("claude.cmd") ?? FindInPathWindows("claude.ps1") ?? FindInPathWindows("claude");
if (!string.IsNullOrEmpty(fromWhere)) return fromWhere;
#endif
return null;
}
// Linux
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
"/usr/local/bin/claude",
"/usr/bin/claude",
Path.Combine(home, ".local", "bin", "claude"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/usr/local/bin:/usr/bin:/bin");
#else
return null;
#endif
}
}
// Attempt to resolve claude from NVM-managed Node installations, choosing the newest version
private static string ResolveClaudeFromNvm(string home)
{
try
{
if (string.IsNullOrEmpty(home)) return null;
string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node");
if (!Directory.Exists(nvmNodeDir)) return null;
string bestPath = null;
Version bestVersion = null;
foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir))
{
string name = Path.GetFileName(versionDir);
if (string.IsNullOrEmpty(name)) continue;
if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
// Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0
string versionStr = name.Substring(1);
int dashIndex = versionStr.IndexOf('-');
if (dashIndex > 0)
{
versionStr = versionStr.Substring(0, dashIndex);
}
if (Version.TryParse(versionStr, out Version parsed))
{
string candidate = Path.Combine(versionDir, "bin", "claude");
if (File.Exists(candidate))
{
if (bestVersion == null || parsed > bestVersion)
{
bestVersion = parsed;
bestPath = candidate;
}
}
}
}
}
return bestPath;
}
catch { return null; }
}
// Explicitly set the Claude CLI absolute path override in EditorPrefs
internal static void SetClaudeCliPath(string absolutePath)
{
try
{
if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath))
{
EditorPrefs.SetString(PrefClaude, absolutePath);
}
}
catch { }
}
// Clear any previously set Claude CLI override path
internal static void ClearClaudeCliPath()
{
try
{
if (EditorPrefs.HasKey(PrefClaude))
{
EditorPrefs.DeleteKey(PrefClaude);
}
}
catch { }
}
internal static bool TryRun(
string file,
string args,
string workingDir,
out string stdout,
out string stderr,
int timeoutMs = 15000,
string extraPathPrepend = null)
{
stdout = string.Empty;
stderr = string.Empty;
try
{
// Handle PowerShell scripts on Windows by invoking through powershell.exe
bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase);
var psi = new ProcessStartInfo
{
FileName = isPs1 ? "powershell.exe" : file,
Arguments = isPs1
? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim()
: args,
WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
if (!string.IsNullOrEmpty(extraPathPrepend))
{
string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath)
? extraPathPrepend
: (extraPathPrepend + System.IO.Path.PathSeparator + currentPath);
}
using var process = new Process { StartInfo = psi, EnableRaisingEvents = false };
var sb = new StringBuilder();
var se = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) sb.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); };
if (!process.Start()) return false;
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (!process.WaitForExit(timeoutMs))
{
try { process.Kill(); } catch { }
return false;
}
// Ensure async buffers are flushed
process.WaitForExit();
stdout = sb.ToString();
stderr = se.ToString();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
/// <summary>
/// Cross-platform path lookup. Uses 'where' on Windows, 'which' on macOS/Linux.
/// Returns the full path if found, null otherwise.
/// </summary>
internal static string FindInPath(string executable, string extraPathPrepend = null)
{
#if UNITY_EDITOR_WIN
return FindInPathWindows(executable, extraPathPrepend);
#elif UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which(executable, extraPathPrepend ?? string.Empty);
#else
return null;
#endif
}
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
private static string Which(string exe, string prependPath)
{
try
{
var psi = new ProcessStartInfo("/usr/bin/which", exe)
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path);
using var p = Process.Start(psi);
if (p == null) return null;
var so = new StringBuilder();
p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
p.BeginOutputReadLine();
if (!p.WaitForExit(1500))
{
try { p.Kill(); } catch { }
return null;
}
p.WaitForExit();
string output = so.ToString().Trim();
return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null;
}
catch { return null; }
}
#endif
#if UNITY_EDITOR_WIN
private static string FindInPathWindows(string exe, string extraPathPrepend = null)
{
try
{
string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
string effectivePath = string.IsNullOrEmpty(extraPathPrepend)
? currentPath
: (string.IsNullOrEmpty(currentPath) ? extraPathPrepend : extraPathPrepend + Path.PathSeparator + currentPath);
var psi = new ProcessStartInfo("where", exe)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
if (!string.IsNullOrEmpty(effectivePath))
{
psi.EnvironmentVariables["PATH"] = effectivePath;
}
using var p = Process.Start(psi);
if (p == null) return null;
var so = new StringBuilder();
p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
p.BeginOutputReadLine();
if (!p.WaitForExit(1500))
{
try { p.Kill(); } catch { }
return null;
}
p.WaitForExit();
string first = so.ToString()
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null;
}
catch { return null; }
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,370 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for finding and looking up GameObjects in the scene.
/// Provides search functionality by name, tag, layer, component, path, and instance ID.
/// </summary>
public static class GameObjectLookup
{
/// <summary>
/// Supported search methods for finding GameObjects.
/// </summary>
public enum SearchMethod
{
ByName,
ByTag,
ByLayer,
ByComponent,
ByPath,
ById
}
/// <summary>
/// Parses a search method string into the enum value.
/// </summary>
public static SearchMethod ParseSearchMethod(string method)
{
if (string.IsNullOrEmpty(method))
return SearchMethod.ByName;
return method.ToLowerInvariant() switch
{
"by_name" => SearchMethod.ByName,
"by_tag" => SearchMethod.ByTag,
"by_layer" => SearchMethod.ByLayer,
"by_component" => SearchMethod.ByComponent,
"by_path" => SearchMethod.ByPath,
"by_id" => SearchMethod.ById,
_ => SearchMethod.ByName
};
}
/// <summary>
/// Finds a single GameObject based on the target and search method.
/// </summary>
/// <param name="target">The target identifier (name, ID, path, etc.)</param>
/// <param name="searchMethod">The search method to use</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <returns>The found GameObject or null</returns>
public static GameObject FindByTarget(JToken target, string searchMethod, bool includeInactive = false)
{
if (target == null)
return null;
var results = SearchGameObjects(searchMethod, target.ToString(), includeInactive, 1);
return results.Count > 0 ? FindById(results[0]) : null;
}
/// <summary>
/// Finds a GameObject by its instance ID.
/// </summary>
public static GameObject FindById(int instanceId)
{
#pragma warning disable CS0618 // Type or member is obsolete
return EditorUtility.InstanceIDToObject(instanceId) as GameObject;
#pragma warning restore CS0618
}
/// <summary>
/// Searches for GameObjects and returns their instance IDs.
/// </summary>
/// <param name="searchMethod">The search method string (by_name, by_tag, etc.)</param>
/// <param name="searchTerm">The term to search for</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
/// <returns>List of instance IDs</returns>
public static List<int> SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0)
{
var method = ParseSearchMethod(searchMethod);
return SearchGameObjects(method, searchTerm, includeInactive, maxResults);
}
/// <summary>
/// Searches for GameObjects and returns their instance IDs.
/// </summary>
/// <param name="method">The search method</param>
/// <param name="searchTerm">The term to search for</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
/// <returns>List of instance IDs</returns>
public static List<int> SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0)
{
var results = new List<int>();
switch (method)
{
case SearchMethod.ById:
if (int.TryParse(searchTerm, out int instanceId))
{
#pragma warning disable CS0618 // Type or member is obsolete
var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
#pragma warning restore CS0618
if (obj != null && (includeInactive || obj.activeInHierarchy))
{
results.Add(instanceId);
}
}
break;
case SearchMethod.ByName:
results.AddRange(SearchByName(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByPath:
results.AddRange(SearchByPath(searchTerm, includeInactive));
break;
case SearchMethod.ByTag:
results.AddRange(SearchByTag(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByLayer:
results.AddRange(SearchByLayer(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByComponent:
results.AddRange(SearchByComponent(searchTerm, includeInactive, maxResults));
break;
}
return results;
}
private static IEnumerable<int> SearchByName(string name, bool includeInactive, int maxResults)
{
var allObjects = GetAllSceneObjects(includeInactive);
var matching = allObjects.Where(go => go.name == name);
if (maxResults > 0)
matching = matching.Take(maxResults);
return matching.Select(go => go.GetInstanceID());
}
private static IEnumerable<int> SearchByPath(string path, bool includeInactive)
{
// Check Prefab Stage first - GameObject.Find() doesn't work in Prefab Stage
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
// Use GetAllSceneObjects which already handles Prefab Stage
var allObjects = GetAllSceneObjects(includeInactive);
foreach (var go in allObjects)
{
if (MatchesPath(go, path))
{
yield return go.GetInstanceID();
}
}
yield break;
}
// Normal scene mode
// NOTE: Unity's GameObject.Find(path) only finds ACTIVE GameObjects.
// If includeInactive=true, we need to search manually to find inactive objects.
if (includeInactive)
{
// Search manually to support inactive objects
var allObjects = GetAllSceneObjects(true);
foreach (var go in allObjects)
{
if (MatchesPath(go, path))
{
yield return go.GetInstanceID();
}
}
}
else
{
// Use GameObject.Find for active objects only (Unity API limitation)
var found = GameObject.Find(path);
if (found != null)
{
yield return found.GetInstanceID();
}
}
}
private static IEnumerable<int> SearchByTag(string tag, bool includeInactive, int maxResults)
{
GameObject[] taggedObjects;
try
{
if (includeInactive)
{
// FindGameObjectsWithTag doesn't find inactive, so we need to iterate all
var allObjects = GetAllSceneObjects(true);
taggedObjects = allObjects.Where(go => go.CompareTag(tag)).ToArray();
}
else
{
taggedObjects = GameObject.FindGameObjectsWithTag(tag);
}
}
catch (UnityException)
{
// Tag doesn't exist
yield break;
}
var results = taggedObjects.AsEnumerable();
if (maxResults > 0)
results = results.Take(maxResults);
foreach (var go in results)
{
yield return go.GetInstanceID();
}
}
private static IEnumerable<int> SearchByLayer(string layerName, bool includeInactive, int maxResults)
{
int layer = LayerMask.NameToLayer(layerName);
if (layer == -1)
{
// Try parsing as layer number
if (!int.TryParse(layerName, out layer) || layer < 0 || layer > 31)
{
yield break;
}
}
var allObjects = GetAllSceneObjects(includeInactive);
var matching = allObjects.Where(go => go.layer == layer);
if (maxResults > 0)
matching = matching.Take(maxResults);
foreach (var go in matching)
{
yield return go.GetInstanceID();
}
}
private static IEnumerable<int> SearchByComponent(string componentTypeName, bool includeInactive, int maxResults)
{
Type componentType = FindComponentType(componentTypeName);
if (componentType == null)
{
McpLog.Warn($"[GameObjectLookup] Component type '{componentTypeName}' not found.");
yield break;
}
var allObjects = GetAllSceneObjects(includeInactive);
var count = 0;
foreach (var go in allObjects)
{
if (go.GetComponent(componentType) != null)
{
yield return go.GetInstanceID();
count++;
if (maxResults > 0 && count >= maxResults)
yield break;
}
}
}
/// <summary>
/// Gets all GameObjects in the current scene.
/// </summary>
public static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
{
// Check Prefab Stage first
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null && prefabStage.prefabContentsRoot != null)
{
// Use Prefab Stage's prefabContentsRoot
foreach (var go in GetObjectAndDescendants(prefabStage.prefabContentsRoot, includeInactive))
{
yield return go;
}
yield break;
}
// Normal scene mode
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid())
yield break;
var rootObjects = scene.GetRootGameObjects();
foreach (var root in rootObjects)
{
foreach (var go in GetObjectAndDescendants(root, includeInactive))
{
yield return go;
}
}
}
private static IEnumerable<GameObject> GetObjectAndDescendants(GameObject obj, bool includeInactive)
{
if (!includeInactive && !obj.activeInHierarchy)
yield break;
yield return obj;
foreach (Transform child in obj.transform)
{
foreach (var descendant in GetObjectAndDescendants(child.gameObject, includeInactive))
{
yield return descendant;
}
}
}
/// <summary>
/// Finds a component type by name, searching loaded assemblies.
/// </summary>
/// <remarks>
/// Delegates to UnityTypeResolver.ResolveComponent() for unified type resolution.
/// </remarks>
public static Type FindComponentType(string typeName)
{
return UnityTypeResolver.ResolveComponent(typeName);
}
/// <summary>
/// Checks whether a GameObject matches a path or trailing path segment.
/// </summary>
internal static bool MatchesPath(GameObject go, string path)
{
if (go == null || string.IsNullOrEmpty(path))
return false;
var goPath = GetGameObjectPath(go);
return goPath == path || goPath.EndsWith("/" + path);
}
/// <summary>
/// Gets the hierarchical path of a GameObject.
/// </summary>
public static string GetGameObjectPath(GameObject obj)
{
if (obj == null)
return string.Empty;
var path = obj.name;
var parent = obj.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
}
}

View File

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

View File

@@ -0,0 +1,666 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Runtime.Serialization; // For Converters
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Handles serialization of GameObjects and Components for MCP responses.
/// Includes reflection helpers and caching for performance.
/// </summary>
public static class GameObjectSerializer
{
// --- Data Serialization ---
/// <summary>
/// Creates a serializable representation of a GameObject.
/// </summary>
public static object GetGameObjectData(GameObject go)
{
if (go == null)
return null;
return new
{
name = go.name,
instanceID = go.GetInstanceID(),
tag = go.tag,
layer = go.layer,
activeSelf = go.activeSelf,
activeInHierarchy = go.activeInHierarchy,
isStatic = go.isStatic,
scenePath = go.scene.path, // Identify which scene it belongs to
transform = new // Serialize transform components carefully to avoid JSON issues
{
// Serialize Vector3 components individually to prevent self-referencing loops.
// The default serializer can struggle with properties like Vector3.normalized.
position = new
{
x = go.transform.position.x,
y = go.transform.position.y,
z = go.transform.position.z,
},
localPosition = new
{
x = go.transform.localPosition.x,
y = go.transform.localPosition.y,
z = go.transform.localPosition.z,
},
rotation = new
{
x = go.transform.rotation.eulerAngles.x,
y = go.transform.rotation.eulerAngles.y,
z = go.transform.rotation.eulerAngles.z,
},
localRotation = new
{
x = go.transform.localRotation.eulerAngles.x,
y = go.transform.localRotation.eulerAngles.y,
z = go.transform.localRotation.eulerAngles.z,
},
scale = new
{
x = go.transform.localScale.x,
y = go.transform.localScale.y,
z = go.transform.localScale.z,
},
forward = new
{
x = go.transform.forward.x,
y = go.transform.forward.y,
z = go.transform.forward.z,
},
up = new
{
x = go.transform.up.x,
y = go.transform.up.y,
z = go.transform.up.z,
},
right = new
{
x = go.transform.right.x,
y = go.transform.right.y,
z = go.transform.right.z,
},
},
parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent
// Optionally include components, but can be large
// components = go.GetComponents<Component>().Select(c => GetComponentData(c)).ToList()
// Or just component names:
componentNames = go.GetComponents<Component>()
.Select(c => c.GetType().FullName)
.ToList(),
};
}
// --- Metadata Caching for Reflection ---
private class CachedMetadata
{
public readonly List<PropertyInfo> SerializableProperties;
public readonly List<FieldInfo> SerializableFields;
public CachedMetadata(List<PropertyInfo> properties, List<FieldInfo> fields)
{
SerializableProperties = properties;
SerializableFields = fields;
}
}
// Key becomes Tuple<Type, bool>
private static readonly Dictionary<Tuple<Type, bool>, CachedMetadata> _metadataCache = new Dictionary<Tuple<Type, bool>, CachedMetadata>();
// --- End Metadata Caching ---
/// <summary>
/// Checks if a type is or derives from a type with the specified full name.
/// Used to detect special-case components including their subclasses.
/// </summary>
private static bool IsOrDerivedFrom(Type type, string baseTypeFullName)
{
Type current = type;
while (current != null)
{
if (current.FullName == baseTypeFullName)
return true;
current = current.BaseType;
}
return false;
}
/// <summary>
/// Serializes a UnityEngine.Object reference to a dictionary with name, instanceID, and assetPath.
/// Used for consistent serialization of asset references in special-case component handlers.
/// </summary>
/// <param name="obj">The Unity object to serialize</param>
/// <param name="includeAssetPath">Whether to include the asset path (default true)</param>
/// <returns>A dictionary with the object's reference info, or null if obj is null</returns>
private static Dictionary<string, object> SerializeAssetReference(UnityEngine.Object obj, bool includeAssetPath = true)
{
if (obj == null) return null;
var result = new Dictionary<string, object>
{
{ "name", obj.name },
{ "instanceID", obj.GetInstanceID() }
};
if (includeAssetPath)
{
var assetPath = AssetDatabase.GetAssetPath(obj);
result["assetPath"] = string.IsNullOrEmpty(assetPath) ? null : assetPath;
}
return result;
}
/// <summary>
/// Creates a serializable representation of a Component, attempting to serialize
/// public properties and fields using reflection, with caching and control over non-public fields.
/// </summary>
// Add the flag parameter here
public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true)
{
// --- Add Early Logging ---
// McpLog.Info($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})");
// --- End Early Logging ---
if (c == null) return null;
Type componentType = c.GetType();
// --- Special handling for Transform to avoid reflection crashes and problematic properties ---
if (componentType == typeof(Transform))
{
Transform tr = c as Transform;
// McpLog.Info($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})");
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", tr.GetInstanceID() },
// Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'.
{ "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, // Use Euler angles
{ "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 },
{ "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 },
{ "childCount", tr.childCount },
// Include standard Object/Component properties
{ "name", tr.name },
{ "tag", tr.tag },
{ "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 }
};
}
// --- End Special handling for Transform ---
// --- Special handling for Camera to avoid matrix-related crashes ---
if (componentType == typeof(Camera))
{
Camera cam = c as Camera;
var cameraProperties = new Dictionary<string, object>();
// List of safe properties to serialize
var safeProperties = new Dictionary<string, Func<object>>
{
{ "nearClipPlane", () => cam.nearClipPlane },
{ "farClipPlane", () => cam.farClipPlane },
{ "fieldOfView", () => cam.fieldOfView },
{ "renderingPath", () => (int)cam.renderingPath },
{ "actualRenderingPath", () => (int)cam.actualRenderingPath },
{ "allowHDR", () => cam.allowHDR },
{ "allowMSAA", () => cam.allowMSAA },
{ "allowDynamicResolution", () => cam.allowDynamicResolution },
{ "forceIntoRenderTexture", () => cam.forceIntoRenderTexture },
{ "orthographicSize", () => cam.orthographicSize },
{ "orthographic", () => cam.orthographic },
{ "opaqueSortMode", () => (int)cam.opaqueSortMode },
{ "transparencySortMode", () => (int)cam.transparencySortMode },
{ "depth", () => cam.depth },
{ "aspect", () => cam.aspect },
{ "cullingMask", () => cam.cullingMask },
{ "eventMask", () => cam.eventMask },
{ "backgroundColor", () => cam.backgroundColor },
{ "clearFlags", () => (int)cam.clearFlags },
{ "stereoEnabled", () => cam.stereoEnabled },
{ "stereoSeparation", () => cam.stereoSeparation },
{ "stereoConvergence", () => cam.stereoConvergence },
{ "enabled", () => cam.enabled },
{ "name", () => cam.name },
{ "tag", () => cam.tag },
{ "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } }
};
foreach (var prop in safeProperties)
{
try
{
var value = prop.Value();
if (value != null)
{
AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value);
}
}
catch (Exception)
{
// Silently skip any property that fails
continue;
}
}
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", cam.GetInstanceID() },
{ "properties", cameraProperties }
};
}
// --- End Special handling for Camera ---
// --- Special handling for UIDocument to avoid infinite loops from VisualElement hierarchy (Issue #585) ---
// UIDocument.rootVisualElement contains circular parent/child references that cause infinite serialization loops.
// Use IsOrDerivedFrom to also catch subclasses of UIDocument.
if (IsOrDerivedFrom(componentType, "UnityEngine.UIElements.UIDocument"))
{
var uiDocProperties = new Dictionary<string, object>();
try
{
// Get panelSettings reference safely
var panelSettingsProp = componentType.GetProperty("panelSettings");
if (panelSettingsProp != null)
{
var panelSettings = panelSettingsProp.GetValue(c) as UnityEngine.Object;
uiDocProperties["panelSettings"] = SerializeAssetReference(panelSettings);
}
// Get visualTreeAsset reference safely (the UXML file)
var visualTreeAssetProp = componentType.GetProperty("visualTreeAsset");
if (visualTreeAssetProp != null)
{
var visualTreeAsset = visualTreeAssetProp.GetValue(c) as UnityEngine.Object;
uiDocProperties["visualTreeAsset"] = SerializeAssetReference(visualTreeAsset);
}
// Get sortingOrder safely
var sortingOrderProp = componentType.GetProperty("sortingOrder");
if (sortingOrderProp != null)
{
uiDocProperties["sortingOrder"] = sortingOrderProp.GetValue(c);
}
// Get enabled state (from Behaviour base class)
var enabledProp = componentType.GetProperty("enabled");
if (enabledProp != null)
{
uiDocProperties["enabled"] = enabledProp.GetValue(c);
}
// Get parentUI reference safely (no asset path needed - it's a scene reference)
var parentUIProp = componentType.GetProperty("parentUI");
if (parentUIProp != null)
{
var parentUI = parentUIProp.GetValue(c) as UnityEngine.Object;
uiDocProperties["parentUI"] = SerializeAssetReference(parentUI, includeAssetPath: false);
}
// NOTE: rootVisualElement is intentionally skipped - it contains circular
// parent/child references that cause infinite serialization loops
uiDocProperties["_note"] = "rootVisualElement skipped to prevent circular reference loops";
}
catch (Exception e)
{
McpLog.Warn($"[GetComponentData] Error reading UIDocument properties: {e.Message}");
}
// Return structure matches Camera special handling (typeName, instanceID, properties)
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", c.GetInstanceID() },
{ "properties", uiDocProperties }
};
}
// --- End Special handling for UIDocument ---
var data = new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", c.GetInstanceID() }
};
// --- Get Cached or Generate Metadata (using new cache key) ---
Tuple<Type, bool> cacheKey = new Tuple<Type, bool>(componentType, includeNonPublicSerializedFields);
if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData))
{
var propertiesToCache = new List<PropertyInfo>();
var fieldsToCache = new List<FieldInfo>();
// Traverse the hierarchy from the component type up to MonoBehaviour
Type currentType = componentType;
while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object))
{
// Get properties declared only at the current type level
BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
foreach (var propInfo in currentType.GetProperties(propFlags))
{
// Basic filtering (readable, not indexer, not transform which is handled elsewhere)
if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue;
// Add if not already added (handles overrides - keep the most derived version)
if (!propertiesToCache.Any(p => p.Name == propInfo.Name))
{
propertiesToCache.Add(propInfo);
}
}
// Get fields declared only at the current type level (both public and non-public)
BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
var declaredFields = currentType.GetFields(fieldFlags);
// Process the declared Fields for caching
foreach (var fieldInfo in declaredFields)
{
if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields
// Add if not already added (handles hiding - keep the most derived version)
if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
bool shouldInclude = false;
if (includeNonPublicSerializedFields)
{
// If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal)
var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true);
shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField);
}
else // includeNonPublicSerializedFields is FALSE
{
// If FALSE, include ONLY if it is explicitly Public.
shouldInclude = fieldInfo.IsPublic;
}
if (shouldInclude)
{
fieldsToCache.Add(fieldInfo);
}
}
// Move to the base type
currentType = currentType.BaseType;
}
// --- End Hierarchy Traversal ---
cachedData = new CachedMetadata(propertiesToCache, fieldsToCache);
_metadataCache[cacheKey] = cachedData; // Add to cache with combined key
}
// --- End Get Cached or Generate Metadata ---
// --- Use cached metadata ---
var serializablePropertiesOutput = new Dictionary<string, object>();
// --- Add Logging Before Property Loop ---
// McpLog.Info($"[GetComponentData] Starting property loop for {componentType.Name}...");
// --- End Logging Before Property Loop ---
// Use cached properties
foreach (var propInfo in cachedData.SerializableProperties)
{
string propName = propInfo.Name;
// --- Skip known obsolete/problematic Component shortcut properties ---
bool skipProperty = false;
if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" ||
propName == "light" || propName == "animation" || propName == "constantForce" ||
propName == "renderer" || propName == "audio" || propName == "networkView" ||
propName == "collider" || propName == "collider2D" || propName == "hingeJoint" ||
propName == "particleSystem" ||
// Also skip potentially problematic Matrix properties prone to cycles/errors
propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")
{
// McpLog.Info($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
skipProperty = true;
}
// --- End Skip Generic Properties ---
// --- Skip specific potentially problematic Camera properties ---
if (componentType == typeof(Camera) &&
(propName == "pixelRect" ||
propName == "rect" ||
propName == "cullingMatrix" ||
propName == "useOcclusionCulling" ||
propName == "worldToCameraMatrix" ||
propName == "projectionMatrix" ||
propName == "nonJitteredProjectionMatrix" ||
propName == "previousViewProjectionMatrix" ||
propName == "cameraToWorldMatrix"))
{
// McpLog.Info($"[GetComponentData] Explicitly skipping Camera property: {propName}");
skipProperty = true;
}
// --- End Skip Camera Properties ---
// --- Skip specific potentially problematic Transform properties ---
if (componentType == typeof(Transform) &&
(propName == "lossyScale" ||
propName == "rotation" ||
propName == "worldToLocalMatrix" ||
propName == "localToWorldMatrix"))
{
// McpLog.Info($"[GetComponentData] Explicitly skipping Transform property: {propName}");
skipProperty = true;
}
// --- End Skip Transform Properties ---
// Skip if flagged
if (skipProperty)
{
continue;
}
try
{
// --- Add detailed logging ---
// McpLog.Info($"[GetComponentData] Accessing: {componentType.Name}.{propName}");
// --- End detailed logging ---
// --- Special handling for material/mesh properties in edit mode ---
object value;
if (!Application.isPlaying && (propName == "material" || propName == "materials" || propName == "mesh"))
{
// In edit mode, use sharedMaterial/sharedMesh to avoid instantiation warnings
if ((propName == "material" || propName == "materials") && c is Renderer renderer)
{
if (propName == "material")
value = renderer.sharedMaterial;
else // materials
value = renderer.sharedMaterials;
}
else if (propName == "mesh" && c is MeshFilter meshFilter)
{
value = meshFilter.sharedMesh;
}
else
{
// Fallback to normal property access if type doesn't match
value = propInfo.GetValue(c);
}
}
else
{
value = propInfo.GetValue(c);
}
// --- End special handling ---
Type propType = propInfo.PropertyType;
AddSerializableValue(serializablePropertiesOutput, propName, propType, value);
}
catch (Exception)
{
// McpLog.Warn($"Could not read property {propName} on {componentType.Name}");
}
}
// --- Add Logging Before Field Loop ---
// McpLog.Info($"[GetComponentData] Starting field loop for {componentType.Name}...");
// --- End Logging Before Field Loop ---
// Use cached fields
foreach (var fieldInfo in cachedData.SerializableFields)
{
try
{
// --- Add detailed logging for fields ---
// McpLog.Info($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}");
// --- End detailed logging for fields ---
object value = fieldInfo.GetValue(c);
string fieldName = fieldInfo.Name;
Type fieldType = fieldInfo.FieldType;
AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value);
}
catch (Exception)
{
// McpLog.Warn($"Could not read field {fieldInfo.Name} on {componentType.Name}");
}
}
// --- End Use cached metadata ---
if (serializablePropertiesOutput.Count > 0)
{
data["properties"] = serializablePropertiesOutput;
}
return data;
}
// Helper function to decide how to serialize different types
private static void AddSerializableValue(Dictionary<string, object> dict, string name, Type type, object value)
{
// Simplified: Directly use CreateTokenFromValue which uses the serializer
if (value == null)
{
dict[name] = null;
return;
}
try
{
// Use the helper that employs our custom serializer settings
JToken token = CreateTokenFromValue(value, type);
if (token != null) // Check if serialization succeeded in the helper
{
// Convert JToken back to a basic object structure for the dictionary
dict[name] = ConvertJTokenToPlainObject(token);
}
// If token is null, it means serialization failed and a warning was logged.
}
catch (Exception e)
{
// Catch potential errors during JToken conversion or addition to dictionary
McpLog.Warn($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping.");
}
}
// Helper to convert JToken back to basic object structure
private static object ConvertJTokenToPlainObject(JToken token)
{
if (token == null) return null;
switch (token.Type)
{
case JTokenType.Object:
var objDict = new Dictionary<string, object>();
foreach (var prop in ((JObject)token).Properties())
{
objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value);
}
return objDict;
case JTokenType.Array:
var list = new List<object>();
foreach (var item in (JArray)token)
{
list.Add(ConvertJTokenToPlainObject(item));
}
return list;
case JTokenType.Integer:
return token.ToObject<long>(); // Use long for safety
case JTokenType.Float:
return token.ToObject<double>(); // Use double for safety
case JTokenType.String:
return token.ToObject<string>();
case JTokenType.Boolean:
return token.ToObject<bool>();
case JTokenType.Date:
return token.ToObject<DateTime>();
case JTokenType.Guid:
return token.ToObject<Guid>();
case JTokenType.Uri:
return token.ToObject<Uri>();
case JTokenType.TimeSpan:
return token.ToObject<TimeSpan>();
case JTokenType.Bytes:
return token.ToObject<byte[]>();
case JTokenType.Null:
return null;
case JTokenType.Undefined:
return null; // Treat undefined as null
default:
// Fallback for simple value types not explicitly listed
if (token is JValue jValue && jValue.Value != null)
{
return jValue.Value;
}
// McpLog.Warn($"Unsupported JTokenType encountered: {token.Type}. Returning null.");
return null;
}
}
// --- Define custom JsonSerializerSettings for OUTPUT ---
private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
new Vector3Converter(),
new Vector2Converter(),
new QuaternionConverter(),
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new Matrix4x4Converter(), // Fix #478: Safe Matrix4x4 serialization for Cinemachine
new UnityEngineObjectConverter() // Handles serialization of references
},
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
// ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed
};
private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings);
// --- End Define custom JsonSerializerSettings ---
// Helper to create JToken using the output serializer
private static JToken CreateTokenFromValue(object value, Type type)
{
if (value == null) return JValue.CreateNull();
try
{
// Use the pre-configured OUTPUT serializer instance
return JToken.FromObject(value, _outputSerializer);
}
catch (JsonSerializationException e)
{
McpLog.Warn($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field.");
return null; // Indicate serialization failure
}
catch (Exception e) // Catch other unexpected errors
{
McpLog.Warn($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field.");
return null; // Indicate serialization failure
}
}
}
}

View File

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

View File

@@ -0,0 +1,184 @@
using System;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Helper methods for managing HTTP endpoint URLs used by the MCP bridge.
/// Ensures the stored value is always the base URL (without trailing path),
/// and provides convenience accessors for specific endpoints.
///
/// HTTP Local and HTTP Remote use separate EditorPrefs keys so that switching
/// between scopes does not overwrite the other scope's URL.
/// </summary>
public static class HttpEndpointUtility
{
private const string LocalPrefKey = EditorPrefKeys.HttpBaseUrl;
private const string RemotePrefKey = EditorPrefKeys.HttpRemoteBaseUrl;
private const string DefaultLocalBaseUrl = "http://localhost:8080";
private const string DefaultRemoteBaseUrl = "";
/// <summary>
/// Returns the normalized base URL for the currently active HTTP scope.
/// If the scope is "remote", returns the remote URL; otherwise returns the local URL.
/// </summary>
public static string GetBaseUrl()
{
return IsRemoteScope() ? GetRemoteBaseUrl() : GetLocalBaseUrl();
}
/// <summary>
/// Saves a user-provided URL to the currently active HTTP scope's pref.
/// </summary>
public static void SaveBaseUrl(string userValue)
{
if (IsRemoteScope())
{
SaveRemoteBaseUrl(userValue);
}
else
{
SaveLocalBaseUrl(userValue);
}
}
/// <summary>
/// Returns the normalized local HTTP base URL (always reads local pref).
/// </summary>
public static string GetLocalBaseUrl()
{
string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl);
return NormalizeBaseUrl(stored, DefaultLocalBaseUrl);
}
/// <summary>
/// Saves a user-provided URL to the local HTTP pref.
/// </summary>
public static void SaveLocalBaseUrl(string userValue)
{
string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl);
EditorPrefs.SetString(LocalPrefKey, normalized);
}
/// <summary>
/// Returns the normalized remote HTTP base URL (always reads remote pref).
/// Returns empty string if no remote URL is configured.
/// </summary>
public static string GetRemoteBaseUrl()
{
string stored = EditorPrefs.GetString(RemotePrefKey, DefaultRemoteBaseUrl);
if (string.IsNullOrWhiteSpace(stored))
{
return DefaultRemoteBaseUrl;
}
return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl);
}
/// <summary>
/// Saves a user-provided URL to the remote HTTP pref.
/// </summary>
public static void SaveRemoteBaseUrl(string userValue)
{
if (string.IsNullOrWhiteSpace(userValue))
{
EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl);
return;
}
string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl);
EditorPrefs.SetString(RemotePrefKey, normalized);
}
/// <summary>
/// Builds the JSON-RPC endpoint for the currently active scope (base + /mcp).
/// </summary>
public static string GetMcpRpcUrl()
{
return AppendPathSegment(GetBaseUrl(), "mcp");
}
/// <summary>
/// Builds the local JSON-RPC endpoint (local base + /mcp).
/// </summary>
public static string GetLocalMcpRpcUrl()
{
return AppendPathSegment(GetLocalBaseUrl(), "mcp");
}
/// <summary>
/// Builds the remote JSON-RPC endpoint (remote base + /mcp).
/// Returns empty string if no remote URL is configured.
/// </summary>
public static string GetRemoteMcpRpcUrl()
{
string remoteBase = GetRemoteBaseUrl();
return string.IsNullOrEmpty(remoteBase) ? string.Empty : AppendPathSegment(remoteBase, "mcp");
}
/// <summary>
/// Builds the endpoint used when POSTing custom-tool registration payloads.
/// </summary>
public static string GetRegisterToolsUrl()
{
return AppendPathSegment(GetBaseUrl(), "register-tools");
}
/// <summary>
/// Returns true if the active HTTP transport scope is "remote".
/// </summary>
public static bool IsRemoteScope()
{
string scope = EditorConfigurationCache.Instance.HttpTransportScope;
return string.Equals(scope, "remote", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Returns the <see cref="ConfiguredTransport"/> that matches the current server-side
/// transport selection (Stdio, Http, or HttpRemote).
/// Centralises the 3-way determination so callers avoid duplicated logic.
/// </summary>
public static ConfiguredTransport GetCurrentServerTransport()
{
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttp) return ConfiguredTransport.Stdio;
return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http;
}
/// <summary>
/// Normalizes a URL so that we consistently store just the base (no trailing slash/path).
/// </summary>
private static string NormalizeBaseUrl(string value, string defaultUrl)
{
if (string.IsNullOrWhiteSpace(value))
{
return defaultUrl;
}
string trimmed = value.Trim();
// Ensure scheme exists; default to http:// if user omitted it.
if (!trimmed.Contains("://"))
{
trimmed = $"http://{trimmed}";
}
// Remove trailing slash segments.
trimmed = trimmed.TrimEnd('/');
// Strip trailing "/mcp" (case-insensitive) if provided.
if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[..^4];
}
return trimmed;
}
private static string AppendPathSegment(string baseUrl, string segment)
{
return $"{baseUrl.TrimEnd('/')}/{segment}";
}
}
}

View File

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

View File

@@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
public static class MaterialOps
{
/// <summary>
/// Applies a set of properties (JObject) to a material, handling aliases and structured formats.
/// </summary>
public static bool ApplyProperties(Material mat, JObject properties, JsonSerializer serializer)
{
if (mat == null || properties == null)
return false;
bool modified = false;
// Helper for case-insensitive lookup
JToken GetValue(string key)
{
return properties.Properties()
.FirstOrDefault(p => string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))?.Value;
}
// --- Structured / Legacy Format Handling ---
// Example: Set shader
var shaderToken = GetValue("shader");
if (shaderToken?.Type == JTokenType.String)
{
string shaderRequest = shaderToken.ToString();
// Set shader
Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest);
if (newShader != null && mat.shader != newShader)
{
mat.shader = newShader;
modified = true;
}
}
// Example: Set color property (structured)
var colorToken = GetValue("color");
if (colorToken is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat);
if (colorProps["value"] is JArray colArr && colArr.Count >= 3)
{
try
{
Color newColor = ParseColor(colArr, serializer);
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to parse color for property '{propName}': {ex.Message}");
}
}
}
else if (colorToken is JArray colorArr) // Structured shorthand
{
string propName = GetMainColorPropertyName(mat);
try
{
Color newColor = ParseColor(colorArr, serializer);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to parse color array: {ex.Message}");
}
}
// Example: Set float property (structured)
var floatToken = GetValue("float");
if (floatToken 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)
{
McpLog.Warn($"[MaterialOps] Failed to set float property '{propName}': {ex.Message}");
}
}
}
// Example: Set texture property (structured)
{
var texToken = GetValue("texture");
if (texToken is JObject texProps)
{
string rawName = (texProps["name"] ?? texProps["Name"])?.ToString();
string texPath = (texProps["path"] ?? texProps["Path"])?.ToString();
if (!string.IsNullOrEmpty(texPath))
{
var sanitizedPath = AssetPathUtility.SanitizeAssetPath(texPath);
var newTex = AssetDatabase.LoadAssetAtPath<Texture>(sanitizedPath);
// Use ResolvePropertyName to handle aliases even for structured texture names
string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName;
string targetProp = ResolvePropertyName(mat, candidateName);
if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp))
{
if (mat.GetTexture(targetProp) != newTex)
{
mat.SetTexture(targetProp, newTex);
modified = true;
}
}
}
}
}
// --- Direct Property Assignment (Flexible) ---
var reservedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" };
foreach (var prop in properties.Properties())
{
if (reservedKeys.Contains(prop.Name)) continue;
string shaderProp = ResolvePropertyName(mat, prop.Name);
JToken v = prop.Value;
if (TrySetShaderProperty(mat, shaderProp, v, serializer))
{
modified = true;
}
}
return modified;
}
/// <summary>
/// Resolves common property aliases (e.g. "metallic" -> "_Metallic").
/// </summary>
public static string ResolvePropertyName(Material mat, string name)
{
if (mat == null || string.IsNullOrEmpty(name)) return name;
string[] candidates;
var lower = name.ToLowerInvariant();
switch (lower)
{
case "_color": candidates = new[] { "_Color", "_BaseColor" }; break;
case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break;
case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break;
case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break;
case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break;
case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break;
// Friendly names → shader property names
case "metallic": candidates = new[] { "_Metallic" }; break;
case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break;
case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break;
default: candidates = new[] { name }; break; // keep original as-is
}
foreach (var candidate in candidates)
{
if (mat.HasProperty(candidate)) return candidate;
}
return name;
}
/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// </summary>
public static string GetMainColorPropertyName(Material mat)
{
if (mat == null || mat.shader == null)
return "_Color";
string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" };
foreach (var prop in commonColorProps)
{
if (mat.HasProperty(prop))
return prop;
}
return "_Color";
}
/// <summary>
/// Tries to set a shader property on a material based on a JToken value.
/// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures.
/// </summary>
public static bool TrySetShaderProperty(Material material, string propertyName, JToken value, JsonSerializer serializer)
{
if (material == null || string.IsNullOrEmpty(propertyName) || value == null)
return false;
// Handle stringified JSON
if (value.Type == JTokenType.String)
{
string s = value.ToString();
if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{"))
{
try
{
JToken parsed = JToken.Parse(s);
return TrySetShaderProperty(material, propertyName, parsed, serializer);
}
catch { }
}
}
// Use the serializer to convert the JToken value first
if (value is JArray jArray)
{
if (jArray.Count == 4)
{
if (material.HasProperty(propertyName))
{
try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; }
catch (Exception ex)
{
// Log at Debug level since we'll try other conversions
McpLog.Info($"[MaterialOps] SetColor attempt for '{propertyName}' failed: {ex.Message}");
}
try { Vector4 vec = value.ToObject<Vector4>(serializer); material.SetVector(propertyName, vec); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetVector (Vec4) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
else if (jArray.Count == 3)
{
if (material.HasProperty(propertyName))
{
try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetColor (Vec3) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
else if (jArray.Count == 2)
{
if (material.HasProperty(propertyName))
{
try { Vector2 vec = value.ToObject<Vector2>(serializer); material.SetVector(propertyName, vec); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetVector (Vec2) attempt for '{propertyName}' failed: {ex.Message}");
}
}
}
}
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
{
if (!material.HasProperty(propertyName))
return false;
try { material.SetFloat(propertyName, value.ToObject<float>(serializer)); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetFloat attempt for '{propertyName}' failed: {ex.Message}");
}
}
else if (value.Type == JTokenType.Boolean)
{
if (!material.HasProperty(propertyName))
return false;
try { material.SetFloat(propertyName, value.ToObject<bool>(serializer) ? 1f : 0f); return true; }
catch (Exception ex)
{
McpLog.Info($"[MaterialOps] SetFloat (bool) attempt for '{propertyName}' failed: {ex.Message}");
}
}
else if (value.Type == JTokenType.String)
{
try
{
// Try loading as asset path first (most common case for strings in this context)
string path = value.ToString();
if (!string.IsNullOrEmpty(path) && path.Contains("/")) // Heuristic: paths usually have slashes
{
// We need to handle texture assignment here.
// Since we don't have easy access to AssetDatabase here directly without using UnityEditor namespace (which is imported),
// we can try to load it.
var sanitizedPath = AssetPathUtility.SanitizeAssetPath(path);
Texture tex = AssetDatabase.LoadAssetAtPath<Texture>(sanitizedPath);
if (tex != null && material.HasProperty(propertyName))
{
material.SetTexture(propertyName, tex);
return true;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"SetTexture (string path) for '{propertyName}' failed: {ex.Message}");
}
}
if (value.Type == JTokenType.Object)
{
try
{
Texture texture = value.ToObject<Texture>(serializer);
if (texture != null && material.HasProperty(propertyName))
{
material.SetTexture(propertyName, texture);
return true;
}
}
catch (Exception ex)
{
McpLog.Warn($"SetTexture (object) for '{propertyName}' failed: {ex.Message}");
}
}
McpLog.Warn(
$"[MaterialOps] Unsupported or failed conversion for material property '{propertyName}' from value: {value.ToString(Formatting.None)}"
);
return false;
}
/// <summary>
/// Helper to parse color from JToken (array or object).
/// </summary>
public static Color ParseColor(JToken token, JsonSerializer serializer)
{
if (token.Type == JTokenType.String)
{
string s = token.ToString();
if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{"))
{
try
{
return ParseColor(JToken.Parse(s), serializer);
}
catch { }
}
}
if (token is JArray jArray)
{
if (jArray.Count == 4)
{
return new Color(
(float)jArray[0],
(float)jArray[1],
(float)jArray[2],
(float)jArray[3]
);
}
else if (jArray.Count == 3)
{
return new Color(
(float)jArray[0],
(float)jArray[1],
(float)jArray[2],
1f
);
}
else
{
throw new ArgumentException("Color array must have 3 or 4 elements.");
}
}
try
{
return token.ToObject<Color>(serializer);
}
catch (Exception ex)
{
McpLog.Warn($"[MaterialOps] Failed to parse color from token: {ex.Message}");
throw;
}
}
}
}

View File

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

View File

@@ -0,0 +1,283 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Dependencies;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Shared helper for MCP client configuration management with sophisticated
/// logic for preserving existing configs and handling different client types
/// </summary>
public static class McpConfigurationHelper
{
private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig;
/// <summary>
/// Writes MCP configuration to the specified path using sophisticated logic
/// that preserves existing configuration and only writes when necessary
/// </summary>
public static string WriteMcpConfiguration(string configPath, McpClient mcpClient = null)
{
// 0) Respect explicit lock (hidden pref or UI toggle)
try
{
if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false))
return "Skipped (locked)";
}
catch { }
JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented };
// Read existing config if it exists
string existingJson = "{}";
if (File.Exists(configPath))
{
try
{
existingJson = File.ReadAllText(configPath);
}
catch (Exception e)
{
McpLog.Warn($"Error reading existing config: {e.Message}.");
}
}
// Parse the existing JSON while preserving all properties
dynamic existingConfig;
try
{
if (string.IsNullOrWhiteSpace(existingJson))
{
existingConfig = new JObject();
}
else
{
existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject();
}
}
catch
{
// If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object
if (!string.IsNullOrWhiteSpace(existingJson))
{
McpLog.Warn("UnityMCP: Configuration file could not be parsed; rewriting server block.");
}
existingConfig = new JObject();
}
// Determine existing entry references (command/args)
string existingCommand = null;
string[] existingArgs = null;
bool isVSCode = (mcpClient?.IsVsCodeLayout == true);
try
{
if (isVSCode)
{
existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString();
existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject<string[]>();
}
else
{
existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString();
existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject<string[]>();
}
}
catch { }
// 1) Start from existing, only fill gaps (prefer trusted resolver)
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
if (uvxPath == null) return "uv package manager not found. Please install uv first.";
// Ensure containers exist and write back configuration
JObject existingRoot;
if (existingConfig is JObject eo)
existingRoot = eo;
else
existingRoot = JObject.FromObject(existingConfig);
existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient);
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
EnsureConfigDirectoryExists(configPath);
WriteAtomicFile(configPath, mergedJson);
return "Configured successfully";
}
/// <summary>
/// Configures a Codex client with sophisticated TOML handling
/// </summary>
public static string ConfigureCodexClient(string configPath, McpClient mcpClient)
{
try
{
if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false))
return "Skipped (locked)";
}
catch { }
string existingToml = string.Empty;
if (File.Exists(configPath))
{
try
{
existingToml = File.ReadAllText(configPath);
}
catch (Exception e)
{
McpLog.Warn($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}");
existingToml = string.Empty;
}
}
string existingCommand = null;
string[] existingArgs = null;
if (!string.IsNullOrWhiteSpace(existingToml))
{
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
}
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
if (uvxPath == null)
{
return "uv package manager not found. Please install uv first.";
}
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath);
EnsureConfigDirectoryExists(configPath);
WriteAtomicFile(configPath, updatedToml);
return "Configured successfully";
}
/// <summary>
/// Gets the appropriate config file path for the given MCP client based on OS
/// </summary>
public static string GetClientConfigPath(McpClient mcpClient)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return mcpClient.windowsConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return string.IsNullOrEmpty(mcpClient.macConfigPath)
? mcpClient.linuxConfigPath
: mcpClient.macConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return mcpClient.linuxConfigPath;
}
else
{
return mcpClient.linuxConfigPath; // fallback
}
}
/// <summary>
/// Creates the directory for the config file if it doesn't exist
/// </summary>
public static void EnsureConfigDirectoryExists(string configPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(configPath));
}
public static string ExtractUvxUrl(string[] args)
{
if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--from", StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
public static bool PathsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
try
{
string na = Path.GetFullPath(a.Trim());
string nb = Path.GetFullPath(b.Trim());
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(na, nb, StringComparison.Ordinal);
}
catch
{
return false;
}
}
public static void WriteAtomicFile(string path, string contents)
{
string tmp = path + ".tmp";
string backup = path + ".backup";
bool writeDone = false;
try
{
File.WriteAllText(tmp, contents, new UTF8Encoding(false));
try
{
File.Replace(tmp, path, backup);
writeDone = true;
}
catch (FileNotFoundException)
{
File.Move(tmp, path);
writeDone = true;
}
catch (PlatformNotSupportedException)
{
if (File.Exists(path))
{
try
{
if (File.Exists(backup)) File.Delete(backup);
}
catch { }
File.Move(path, backup);
}
File.Move(tmp, path);
writeDone = true;
}
}
catch (Exception ex)
{
try
{
if (!writeDone && File.Exists(backup))
{
try { File.Copy(backup, path, true); } catch { }
}
}
catch { }
throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { }
}
}
}
}

View File

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

View File

@@ -0,0 +1,62 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility for persisting tool state across domain reloads. State is stored in
/// Library so it stays local to the project and is cleared by Unity as needed.
/// </summary>
public static class McpJobStateStore
{
private static string GetStatePath(string toolName)
{
if (string.IsNullOrEmpty(toolName))
{
throw new ArgumentException("toolName cannot be null or empty", nameof(toolName));
}
var libraryPath = Path.Combine(Application.dataPath, "..", "Library");
var fileName = $"McpState_{toolName}.json";
return Path.GetFullPath(Path.Combine(libraryPath, fileName));
}
public static void SaveState<T>(string toolName, T state)
{
var path = GetStatePath(toolName);
Directory.CreateDirectory(Path.GetDirectoryName(path));
var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance<T>());
File.WriteAllText(path, json);
}
public static T LoadState<T>(string toolName)
{
var path = GetStatePath(toolName);
if (!File.Exists(path))
{
return default;
}
try
{
var json = File.ReadAllText(path);
return JsonConvert.DeserializeObject<T>(json);
}
catch (Exception)
{
return default;
}
}
public static void ClearState(string toolName)
{
var path = GetStatePath(toolName);
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View File

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

View File

@@ -0,0 +1,53 @@
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
internal static class McpLog
{
private const string InfoPrefix = "<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>:";
private const string DebugPrefix = "<b><color=#6AA84F>MCP-FOR-UNITY</color></b>:";
private const string WarnPrefix = "<b><color=#cc7a00>MCP-FOR-UNITY</color></b>:";
private const string ErrorPrefix = "<b><color=#cc3333>MCP-FOR-UNITY</color></b>:";
private static volatile bool _debugEnabled = ReadDebugPreference();
private static bool IsDebugEnabled() => _debugEnabled;
private static bool ReadDebugPreference()
{
try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); }
catch { return false; }
}
public static void SetDebugLoggingEnabled(bool enabled)
{
_debugEnabled = enabled;
try { EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, enabled); }
catch { }
}
public static void Debug(string message)
{
if (!IsDebugEnabled()) return;
UnityEngine.Debug.Log($"{DebugPrefix} {message}");
}
public static void Info(string message, bool always = true)
{
if (!always && !IsDebugEnabled()) return;
UnityEngine.Debug.Log($"{InfoPrefix} {message}");
}
public static void Warn(string message)
{
UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}");
}
public static void Error(string message)
{
UnityEngine.Debug.LogError($"{ErrorPrefix} {message}");
}
}
}

View File

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

View File

@@ -0,0 +1,202 @@
using System;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Resolves Unity Objects by instruction (handles GameObjects, Components, Assets).
/// Extracted from ManageGameObject to eliminate cross-tool dependencies.
/// </summary>
public static class ObjectResolver
{
/// <summary>
/// Resolves any Unity Object by instruction.
/// </summary>
/// <typeparam name="T">The type of Unity Object to resolve</typeparam>
/// <param name="instruction">JObject with "find" (required), "method" (optional), "component" (optional)</param>
/// <returns>The resolved object, or null if not found</returns>
public static T Resolve<T>(JObject instruction) where T : UnityEngine.Object
{
return Resolve(instruction, typeof(T)) as T;
}
/// <summary>
/// Resolves any Unity Object by instruction.
/// </summary>
/// <param name="instruction">JObject with "find" (required), "method" (optional), "component" (optional)</param>
/// <param name="targetType">The type of Unity Object to resolve</param>
/// <returns>The resolved object, or null if not found</returns>
public static UnityEngine.Object Resolve(JObject instruction, Type targetType)
{
if (instruction == null)
return null;
string findTerm = instruction["find"]?.ToString();
string method = instruction["method"]?.ToString()?.ToLower();
string componentName = instruction["component"]?.ToString();
if (string.IsNullOrEmpty(findTerm))
{
McpLog.Warn("[ObjectResolver] Find instruction missing 'find' term.");
return null;
}
// Use a flexible default search method if none provided
string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method;
// --- Asset Search ---
// Normalize path separators before checking asset paths
string normalizedPath = AssetPathUtility.NormalizeSeparators(findTerm);
// If the target is an asset type, try AssetDatabase first
if (IsAssetType(targetType) ||
(typeof(GameObject).IsAssignableFrom(targetType) && normalizedPath.StartsWith("Assets/")))
{
UnityEngine.Object asset = TryLoadAsset(normalizedPath, targetType);
if (asset != null)
return asset;
// If still not found, fall through to scene search
}
// --- Scene Object Search ---
GameObject foundGo = GameObjectLookup.FindByTarget(new JValue(findTerm), searchMethodToUse, includeInactive: false);
if (foundGo == null)
{
return null;
}
// Get the target object/component from the found GameObject
if (targetType == typeof(GameObject))
{
return foundGo;
}
else if (typeof(Component).IsAssignableFrom(targetType))
{
Type componentToGetType = targetType;
if (!string.IsNullOrEmpty(componentName))
{
Type specificCompType = GameObjectLookup.FindComponentType(componentName);
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
{
componentToGetType = specificCompType;
}
else
{
McpLog.Warn($"[ObjectResolver] Could not find component type '{componentName}'. Falling back to target type '{targetType.Name}'.");
}
}
Component foundComp = foundGo.GetComponent(componentToGetType);
if (foundComp == null)
{
McpLog.Warn($"[ObjectResolver] Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
}
return foundComp;
}
else
{
McpLog.Warn($"[ObjectResolver] Find instruction handling not implemented for target type: {targetType.Name}");
return null;
}
}
/// <summary>
/// Convenience method to resolve a GameObject.
/// </summary>
public static GameObject ResolveGameObject(JToken target, string searchMethod = null)
{
if (target == null)
return null;
// If target is a simple value, use GameObjectLookup directly
if (target.Type != JTokenType.Object)
{
return GameObjectLookup.FindByTarget(target, searchMethod ?? "by_id_or_name_or_path");
}
// If target is an instruction object
var instruction = target as JObject;
if (instruction != null)
{
return Resolve<GameObject>(instruction);
}
return null;
}
/// <summary>
/// Convenience method to resolve a Material.
/// </summary>
public static Material ResolveMaterial(string pathOrName)
{
if (string.IsNullOrEmpty(pathOrName))
return null;
var instruction = new JObject { ["find"] = pathOrName };
return Resolve<Material>(instruction);
}
/// <summary>
/// Convenience method to resolve a Texture.
/// </summary>
public static Texture ResolveTexture(string pathOrName)
{
if (string.IsNullOrEmpty(pathOrName))
return null;
var instruction = new JObject { ["find"] = pathOrName };
return Resolve<Texture>(instruction);
}
// --- Private Helpers ---
private static bool IsAssetType(Type type)
{
return typeof(Material).IsAssignableFrom(type) ||
typeof(Texture).IsAssignableFrom(type) ||
typeof(ScriptableObject).IsAssignableFrom(type) ||
type.FullName?.StartsWith("UnityEngine.U2D") == true ||
typeof(AudioClip).IsAssignableFrom(type) ||
typeof(AnimationClip).IsAssignableFrom(type) ||
typeof(Font).IsAssignableFrom(type) ||
typeof(Shader).IsAssignableFrom(type) ||
typeof(ComputeShader).IsAssignableFrom(type);
}
private static UnityEngine.Object TryLoadAsset(string findTerm, Type targetType)
{
// Try loading directly by path first
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType);
if (asset != null)
return asset;
// Try generic load if type-specific failed
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm);
if (asset != null && targetType.IsAssignableFrom(asset.GetType()))
return asset;
// Try finding by name/type using FindAssets
string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}";
string[] guids = AssetDatabase.FindAssets(searchFilter);
if (guids.Length == 1)
{
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
if (asset != null)
return asset;
}
else if (guids.Length > 1)
{
McpLog.Warn($"[ObjectResolver] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name.");
return null;
}
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,149 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Standard pagination request for all paginated tool operations.
/// Provides consistent handling of page_size/pageSize and cursor/page_number parameters.
/// </summary>
public class PaginationRequest
{
/// <summary>
/// Number of items per page. Default is 50.
/// </summary>
public int PageSize { get; set; } = 50;
/// <summary>
/// 0-based cursor position for the current page.
/// </summary>
public int Cursor { get; set; } = 0;
/// <summary>
/// Creates a PaginationRequest from JObject parameters.
/// Accepts both snake_case and camelCase parameter names for flexibility.
/// Converts 1-based page_number to 0-based cursor if needed.
/// </summary>
public static PaginationRequest FromParams(JObject @params, int defaultPageSize = 50)
{
if (@params == null)
return new PaginationRequest { PageSize = defaultPageSize };
// Accept both page_size and pageSize
int pageSize = ParamCoercion.CoerceInt(
@params["page_size"] ?? @params["pageSize"],
defaultPageSize
);
// Accept both cursor (0-based) and page_number (convert 1-based to 0-based)
var cursorToken = @params["cursor"];
var pageNumberToken = @params["page_number"] ?? @params["pageNumber"];
int cursor;
if (cursorToken != null)
{
cursor = ParamCoercion.CoerceInt(cursorToken, 0);
}
else if (pageNumberToken != null)
{
// Convert 1-based page_number to 0-based cursor
int pageNumber = ParamCoercion.CoerceInt(pageNumberToken, 1);
cursor = (pageNumber - 1) * pageSize;
if (cursor < 0) cursor = 0;
}
else
{
cursor = 0;
}
return new PaginationRequest
{
PageSize = pageSize > 0 ? pageSize : defaultPageSize,
Cursor = cursor
};
}
}
/// <summary>
/// Standard pagination response for all paginated tool operations.
/// Provides consistent response structure across all tools.
/// </summary>
/// <typeparam name="T">The type of items in the paginated list</typeparam>
public class PaginationResponse<T>
{
/// <summary>
/// The items on the current page.
/// </summary>
[JsonProperty("items")]
public List<T> Items { get; set; } = new List<T>();
/// <summary>
/// The cursor position for the current page (0-based).
/// </summary>
[JsonProperty("cursor")]
public int Cursor { get; set; }
/// <summary>
/// The cursor for the next page, or null if this is the last page.
/// </summary>
[JsonProperty("nextCursor")]
public int? NextCursor { get; set; }
/// <summary>
/// Total number of items across all pages.
/// </summary>
[JsonProperty("totalCount")]
public int TotalCount { get; set; }
/// <summary>
/// Number of items per page.
/// </summary>
[JsonProperty("pageSize")]
public int PageSize { get; set; }
/// <summary>
/// Whether there are more items after this page.
/// </summary>
[JsonProperty("hasMore")]
public bool HasMore => NextCursor.HasValue;
/// <summary>
/// Creates a PaginationResponse from a full list of items and pagination parameters.
/// </summary>
/// <param name="allItems">The full list of items to paginate</param>
/// <param name="request">The pagination request parameters</param>
/// <returns>A paginated response with the appropriate slice of items</returns>
public static PaginationResponse<T> Create(IList<T> allItems, PaginationRequest request)
{
int totalCount = allItems.Count;
int cursor = request.Cursor;
int pageSize = request.PageSize;
// Clamp cursor to valid range
if (cursor < 0) cursor = 0;
if (cursor > totalCount) cursor = totalCount;
// Get the page of items
var items = new List<T>();
int endIndex = System.Math.Min(cursor + pageSize, totalCount);
for (int i = cursor; i < endIndex; i++)
{
items.Add(allItems[i]);
}
// Calculate next cursor
int? nextCursor = endIndex < totalCount ? endIndex : (int?)null;
return new PaginationResponse<T>
{
Items = items,
Cursor = cursor,
NextCursor = nextCursor,
TotalCount = totalCount,
PageSize = pageSize
};
}
}
}

Some files were not shown because too many files have changed in this diff Show More