升级XR插件版本
This commit is contained in:
3
Packages/MCPForUnity/Editor/AssemblyInfo.cs
Normal file
3
Packages/MCPForUnity/Editor/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")]
|
||||
11
Packages/MCPForUnity/Editor/AssemblyInfo.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/AssemblyInfo.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be61633e00d934610ac1ff8192ffbe3d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Clients.meta
Normal file
8
Packages/MCPForUnity/Editor/Clients.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9d47f01d06964ee7843765d1bd71205
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Clients/Configurators.meta
Normal file
8
Packages/MCPForUnity/Editor/Clients/Configurators.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59ff83375c2c74c8385c4a22549778dd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 331b33961513042e3945d0a1d06615b5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6de06c6bb0399154d840a1e4c84be869
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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'"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d0d22681fc594475db1c189f2d9abdf7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5e5d87c9db57495f842dc366f1ebd65
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 923728a98c8c74cfaa6e9203c408f34e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7037ef8b168e49f79247cb31c3be75a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14a4b9a7f749248d496466c2a3a53e56
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b708eda314746481fb8f4a1fb0652b03
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3286d62ffe5644f5ea60488fd7e6513d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9b73ff071a6043dda1f2ec7d682ef71
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 489f99ffb7e6743e88e3203552c8b37b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2511b0d05271d486bb61f8cc9fd11363
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3ab39e22ae0948ab94beae307f9902e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcc7ead475a4d4ea2978151c217757b8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b528971e189f141d38db577f155bd222
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5a5078d9e6e14027a1abfebf4018634
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
925
Packages/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
Normal file
925
Packages/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d408fd7733cb4a1eb80f785307db2ff
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
60
Packages/MCPForUnity/Editor/Clients/McpClientRegistry.cs
Normal file
60
Packages/MCPForUnity/Editor/Clients/McpClientRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ce08555f995e4e848a826c63f18cb35
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Constants.meta
Normal file
8
Packages/MCPForUnity/Editor/Constants.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7e009cbf3e74f6c987331c2b438ec59
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
10
Packages/MCPForUnity/Editor/Constants/AuthConstants.cs
Normal file
10
Packages/MCPForUnity/Editor/Constants/AuthConstants.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Constants/AuthConstants.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Constants/AuthConstants.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96844bc39e9a94cf18b18f8127f3854f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
66
Packages/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Normal file
66
Packages/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7317786cfb9304b0db20ca73a774b9fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
14
Packages/MCPForUnity/Editor/Constants/HealthStatus.cs
Normal file
14
Packages/MCPForUnity/Editor/Constants/HealthStatus.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Constants/HealthStatus.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Constants/HealthStatus.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c15ed2426f43860479f1b8a99a343d16
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Dependencies.meta
Normal file
8
Packages/MCPForUnity/Editor/Dependencies.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 221a4d6e595be6897a5b17b77aedd4d0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
143
Packages/MCPForUnity/Editor/Dependencies/DependencyManager.cs
Normal file
143
Packages/MCPForUnity/Editor/Dependencies/DependencyManager.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a6d2236d370b4f1db4d0e3d3ce0dcac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Dependencies/Models.meta
Normal file
8
Packages/MCPForUnity/Editor/Dependencies/Models.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c0f2e87395b4c6c9df8c21b6d0fae13
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6df82faa423f4e9ebb13a3dcee8ba19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ddeeeca2f876f4083a84417404175199
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bdbaced669d14798a4ceeebfbff2b22c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67d73d0e8caef4e60942f4419c6b76bf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b682b492eb80d4ed6834b76f72c9f0f3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6f602b0a8ca848859197f9a949a7a5d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44d715aedea2b8b41bf914433bbb2c49
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1aedc29caa5704c07b487d20a27e9334
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/External.meta
Normal file
8
Packages/MCPForUnity/Editor/External.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c11944bcfb9ec4576bab52874b7df584
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
2138
Packages/MCPForUnity/Editor/External/Tommy.cs
vendored
Normal file
2138
Packages/MCPForUnity/Editor/External/Tommy.cs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
11
Packages/MCPForUnity/Editor/External/Tommy.cs.meta
vendored
Normal file
11
Packages/MCPForUnity/Editor/External/Tommy.cs.meta
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ea652131dcdaa44ca8cb35cd1191be3f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/MCPForUnity/Editor/Helpers.meta
Normal file
8
Packages/MCPForUnity/Editor/Helpers.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 94cb070dc5e15024da86150b27699ca0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
430
Packages/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
Normal file
430
Packages/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
319
Packages/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
Normal file
319
Packages/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3e68082ffc0b4cd39d3747673a4cc22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
349
Packages/MCPForUnity/Editor/Helpers/ComponentOps.cs
Normal file
349
Packages/MCPForUnity/Editor/Helpers/ComponentOps.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
Packages/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13dead161bc4540eeb771961df437779
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
194
Packages/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
Normal file
194
Packages/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c07c3369f73943919d9e086a81d1dcc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
324
Packages/MCPForUnity/Editor/Helpers/ExecPath.cs
Normal file
324
Packages/MCPForUnity/Editor/Helpers/ExecPath.cs
Normal 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
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Helpers/ExecPath.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/ExecPath.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
370
Packages/MCPForUnity/Editor/Helpers/GameObjectLookup.cs
Normal file
370
Packages/MCPForUnity/Editor/Helpers/GameObjectLookup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
Packages/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4964205faa8dd4f8a960e58fd8c0d4f7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
666
Packages/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs
Normal file
666
Packages/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64b8ff807bc9a401c82015cbafccffac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
184
Packages/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
Normal file
184
Packages/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2051d90316ea345c09240c80c7138e3b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
397
Packages/MCPForUnity/Editor/Helpers/MaterialOps.cs
Normal file
397
Packages/MCPForUnity/Editor/Helpers/MaterialOps.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a59e8545e32664dae9a696d449f82c3d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
283
Packages/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs
Normal file
283
Packages/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e45ac2a13b4c1ba468b8e3aa67b292ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
62
Packages/MCPForUnity/Editor/Helpers/McpJobStateStore.cs
Normal file
62
Packages/MCPForUnity/Editor/Helpers/McpJobStateStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28912085dd68342f8a9fda8a43c83a59
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
53
Packages/MCPForUnity/Editor/Helpers/McpLog.cs
Normal file
53
Packages/MCPForUnity/Editor/Helpers/McpLog.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Packages/MCPForUnity/Editor/Helpers/McpLog.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/McpLog.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
202
Packages/MCPForUnity/Editor/Helpers/ObjectResolver.cs
Normal file
202
Packages/MCPForUnity/Editor/Helpers/ObjectResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
Packages/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad678f7b0a2e6458bbdb38a15d857acf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
149
Packages/MCPForUnity/Editor/Helpers/Pagination.cs
Normal file
149
Packages/MCPForUnity/Editor/Helpers/Pagination.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
Packages/MCPForUnity/Editor/Helpers/Pagination.cs.meta
Normal file
11
Packages/MCPForUnity/Editor/Helpers/Pagination.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 745564d5894d74c0ca24db39c77bab2c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user