diff --git a/.claude/mcp.json b/.claude/mcp.json new file mode 100644 index 000000000..63da78661 --- /dev/null +++ b/.claude/mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "UnityMCP": { + "type": "stdio", + "command": "uv", + "args": [ + "run", + "--directory", + "${workspaceFolder}/Server", + "src/main.py", + "--transport", + "stdio" + ] + } + } +} diff --git a/.claude/settings.json b/.claude/settings.json index bd3d33632..127026519 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,6 @@ "MultiEdit(reports/**)" ], "deny": [ - "Bash", "WebFetch", "WebSearch", "Task", diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index e732cb8d2..592e0f35b 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1047,13 +1047,13 @@ jobs: def id_from_filename(p: Path): n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) if m: return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) if m: return f"T-{m.group(1).upper()}" - m = re.match(r'GO(\d+)_results\.xml$', n, re.I) + m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) if m: return f"GO-{int(m.group(1))}" return None @@ -1067,6 +1067,10 @@ jobs: return None fragments = sorted(Path('reports').glob('*_results.xml')) + report_names = {p.name for p in fragments} + fragments += sorted(p for p in Path('reports/_staging').glob('*_results.xml') if p.name not in report_names) + if fragments: + print("merge fragments:", ", ".join(p.as_posix() for p in fragments)) added = 0 renamed = 0 @@ -1110,6 +1114,7 @@ jobs: renamed += 1 suite.append(tc) added += 1 + print(f"merge add: {frag.name} -> {tc.get('name')}") if added: # Drop bootstrap placeholder and recompute counts @@ -1126,6 +1131,55 @@ jobs: print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.") PY + # Guard is GO-specific; only parse GO fragments here. + - name: "Guard: ensure GO fragments merged into JUnit" + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import os, re + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + junit_path = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + if not junit_path.exists(): + raise SystemExit(0) + + tree = ET.parse(junit_path) + root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + if suite is None: + raise SystemExit(0) + + def id_from_filename(p: Path): + n = p.name + m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) + if m: + return f"GO-{int(m.group(1))}" + return None + + expected = set() + for p in list(Path("reports").glob("GO-*_results.xml")) + list(Path("reports/_staging").glob("GO-*_results.xml")): + fid = id_from_filename(p) + if fid: + expected.add(fid) + + seen = set() + for tc in suite.findall('.//testcase'): + name = (tc.get('name') or '').strip() + m = re.match(r'(GO-\d+)\b', name) + if m: + seen.add(m.group(1)) + + missing = sorted(expected - seen) + if missing: + print(f"::error::GO fragments present but not merged into JUnit: {' '.join(missing)}") + raise SystemExit(1) + PY + # ---------- Markdown summary from JUnit ---------- - name: Build markdown summary from JUnit if: always() diff --git a/.gitignore b/.gitignore index 1ffda95f9..dc5e443fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ -# AI-related files +# AI-related files (user-specific) .cursorrules .cursorignore .windsurf .codeiumignore .kiro -CLAUDE.md # Code-copy related files .clipignore @@ -58,3 +57,4 @@ reports/ # Local testing harness scripts/local-test/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..dd4731685 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md - Project Overview for AI Assistants + +## What This Project Is + +**MCP for Unity** is a bridge that lets AI assistants (Claude, Cursor, Windsurf, etc.) control the Unity Editor through the Model Context Protocol (MCP). It enables AI-driven game development workflows - creating GameObjects, editing scripts, managing assets, running tests, and more. + +## Architecture + +```text +AI Assistant (Claude/Cursor) + ↓ MCP Protocol (stdio/SSE) +Python Server (Server/src/) + ↓ WebSocket + HTTP +Unity Editor Plugin (MCPForUnity/) + ↓ Unity Editor API +Scene, Assets, Scripts +``` + +**Two codebases, one system:** +- `Server/` - Python MCP server using FastMCP +- `MCPForUnity/` - Unity C# Editor package + +## Directory Structure + +```text +├── Server/ # Python MCP Server +│ ├── src/ +│ │ ├── cli/commands/ # Tool implementations (20 domain modules) +│ │ ├── transport/ # MCP protocol, WebSocket bridge +│ │ ├── services/ # Custom tools, resources +│ │ └── core/ # Telemetry, logging, config +│ └── tests/ # 502 Python tests +├── MCPForUnity/ # Unity Editor Package +│ └── Editor/ +│ ├── Tools/ # C# tool implementations (42 files) +│ ├── Services/ # Bridge, state management +│ ├── Helpers/ # Utilities (27 files) +│ └── Windows/ # Editor UI +├── TestProjects/UnityMCPTests/ # Unity test project (605 tests) +└── tools/ # Build/release scripts +``` + +## Code Philosophy + +### 1. Domain Symmetry +Python CLI commands mirror C# Editor tools. Each domain (materials, prefabs, scripts, etc.) exists in both: +- `Server/src/cli/commands/materials.py` ↔ `MCPForUnity/Editor/Tools/ManageMaterial.cs` + +### 2. Minimal Abstraction +Avoid premature abstraction. Three similar lines of code is better than a helper that's used once. Only abstract when you have 3+ genuine use cases. + +### 3. Delete Rather Than Deprecate +When removing functionality, delete it completely. No `_unused` renames, no `// removed` comments, no backwards-compatibility shims for internal code. + +### 4. Test Coverage Required +Every new feature needs tests. We have 1100+ tests across Python and C#. Run them before PRs. + +### 5. Keep Tools Focused +Each MCP tool does one thing well. Resist the urge to add "convenient" parameters that bloat the API surface. + +### 6. Use Resources for reading. +Keep them smart and "read everything" type resources. That way resource are quick and LLM-friendly. There are plenty of examples in the codebase to model on (gameobject, prefab, etc.) + +## Key Patterns + +### Parameter Handling (C#) +Use `ToolParams` for consistent parameter validation: +```csharp +var p = new ToolParams(parameters); +var pageSize = p.GetInt("page_size", "pageSize") ?? 50; +var name = p.RequireString("name"); +``` + +### Error Handling (Python CLI) +Use the `@handle_unity_errors` decorator: +```python +@handle_unity_errors +async def my_command(ctx, ...): + result = await call_unity_tool(...) +``` + +### Paging Large Results +Always page results that could be large (hierarchies, components, search results): +- Use `page_size` and `cursor` parameters +- Return `next_cursor` when more results exist + +## Common Tasks + +### Running Tests +```bash +# Python +cd Server && uv run pytest tests/ -v + +# Unity - open TestProjects/UnityMCPTests in Unity, use Test Runner window +``` + +### Local Development +1. Set **Server Source Override** in MCP for Unity Advanced Settings to your local `Server/` path +2. Enable **Dev Mode** checkbox to force fresh installs +3. Use `mcp_source.py` to switch Unity package sources +4. Test on Windows and Mac if possible, and multiple clients (Claude Desktop and Claude Code are tricky for configuration as of this writing) + +### Adding a New Tool +1. Add Python command in `Server/src/cli/commands/.py` +2. Add C# implementation in `MCPForUnity/Editor/Tools/Manage.cs` +3. Add tests in both `Server/tests/` and `TestProjects/UnityMCPTests/Assets/Tests/` + +## What Not To Do + +- Don't add features without tests +- Don't create helper functions for one-time operations +- Don't add error handling for scenarios that can't happen +- Don't commit to `main` directly - branch off `beta` for PRs +- Don't add docstrings/comments to code you didn't change diff --git a/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs index 2d73829f8..bf14559f3 100644 --- a/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs @@ -3,6 +3,7 @@ using System.IO; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; using UnityEditor; namespace MCPForUnity.Editor.Clients.Configurators @@ -55,7 +56,7 @@ public override void Configure() public override string GetManualSnippet() { - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttp) { diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs index f55f32316..72861349b 100644 --- a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs @@ -3,6 +3,7 @@ using System.IO; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; using UnityEditor; namespace MCPForUnity.Editor.Clients.Configurators @@ -32,7 +33,7 @@ public ClaudeDesktopConfigurator() : base(new McpClient public override void Configure() { - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttp) { throw new InvalidOperationException("Claude Desktop does not support HTTP transport. Switch to stdio in settings before configuring."); @@ -43,7 +44,7 @@ public override void Configure() public override string GetManualSnippet() { - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttp) { return "# Claude Desktop does not support HTTP transport.\n" + diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs index a0ec94117..34ab535bc 100644 --- a/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs @@ -39,25 +39,39 @@ private static string BuildConfigPath() /// /// Attempts to load and parse the config file. - /// Returns null if file doesn't exist. - /// Returns empty JObject if file exists but contains malformed JSON (logs warning). - /// Throws on I/O errors (permission denied, etc.). + /// 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. /// private JObject TryLoadConfig(string path) { if (!File.Exists(path)) return null; - string content = File.ReadAllText(path); + 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(content) ?? new JObject(); } - catch (JsonException) + catch (JsonException ex) { - // Malformed JSON - return empty object so caller can overwrite with valid config - UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Malformed JSON in {path}, will overwrite with valid config"); - return new JObject(); + // 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; } } @@ -113,8 +127,16 @@ public override void Configure() string path = GetConfigPath(); McpConfigurationHelper.EnsureConfigDirectoryExists(path); - var config = TryLoadConfig(path) ?? new JObject { ["$schema"] = SchemaUrl }; + // 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; diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 00e440cc1..20d97a2f0 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -190,7 +190,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { client.SetStatus(McpStatus.Configured); // Update transport after rewrite based on current server setting - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; } else @@ -221,7 +221,7 @@ public override void Configure() { client.SetStatus(McpStatus.Configured); // Set transport based on current server setting - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; } else @@ -314,7 +314,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { client.SetStatus(McpStatus.Configured); // Update transport after rewrite based on current server setting - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; } else @@ -345,7 +345,7 @@ public override void Configure() { client.SetStatus(McpStatus.Configured); // Set transport based on current server setting - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; client.configuredTransport = useHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; } else @@ -393,7 +393,7 @@ 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 = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; // Resolve claudePath on the main thread (EditorPrefs access) string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite); @@ -658,7 +658,7 @@ private void Register() throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; string args; if (useHttpTransport) @@ -752,7 +752,7 @@ private void Unregister() public override string GetManualSnippet() { string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttpTransport) { diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 118ef083b..a81aaf6a9 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -4,7 +4,7 @@ using System.Linq; using MCPForUnity.Editor.Clients.Configurators; using MCPForUnity.Editor.Constants; -using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Models; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -51,7 +51,7 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) { // Get transport preference (default to HTTP) - bool prefValue = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool prefValue = EditorConfigurationCache.Instance.UseHttpTransport; bool clientSupportsHttp = client?.SupportsHttpTransport != false; bool useHttpTransport = clientSupportsHttp && prefValue; string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty; diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs b/MCPForUnity/Editor/Helpers/ParamCoercion.cs index a6c333118..d19d7bf46 100644 --- a/MCPForUnity/Editor/Helpers/ParamCoercion.cs +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs @@ -44,6 +44,40 @@ public static int CoerceInt(JToken token, int defaultValue) return defaultValue; } + /// + /// Coerces a JToken to a nullable integer value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced integer value or null + public static int? CoerceIntNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)) + return i; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (int)d; + } + catch + { + // Swallow and return null + } + + return null; + } + /// /// Coerces a JToken to a boolean value, handling strings like "true", "1", etc. /// @@ -81,6 +115,43 @@ public static bool CoerceBool(JToken token, bool defaultValue) return defaultValue; } + /// + /// Coerces a JToken to a nullable boolean value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced boolean value or null + public static bool? CoerceBoolNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Boolean) + return token.Value(); + + var s = token.ToString().Trim().ToLowerInvariant(); + if (s.Length == 0) + return null; + + if (bool.TryParse(s, out var b)) + return b; + + if (s == "1" || s == "yes" || s == "on") + return true; + + if (s == "0" || s == "no" || s == "off") + return false; + } + catch + { + // Swallow and return null + } + + return null; + } + /// /// Coerces a JToken to a float value, handling strings and integers. /// @@ -112,6 +183,37 @@ public static float CoerceFloat(JToken token, float defaultValue) return defaultValue; } + /// + /// Coerces a JToken to a nullable float value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced float value or null + public static float? CoerceFloatNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f)) + return f; + } + catch + { + // Swallow and return null + } + + return null; + } + /// /// Coerces a JToken to a string value, with null handling. /// diff --git a/MCPForUnity/Editor/Helpers/StringCaseUtility.cs b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs new file mode 100644 index 000000000..6437b3c24 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Text.RegularExpressions; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for converting between naming conventions (snake_case, camelCase). + /// Consolidates previously duplicated implementations from ToolParams, ManageVFX, + /// BatchExecute, CommandRegistry, and ToolDiscoveryService. + /// + public static class StringCaseUtility + { + /// + /// Converts a camelCase string to snake_case. + /// Example: "searchMethod" -> "search_method", "param1Value" -> "param1_value" + /// + /// The camelCase string to convert + /// The snake_case equivalent, or original string if null/empty + public static string ToSnakeCase(string str) + { + if (string.IsNullOrEmpty(str)) + return str; + + return Regex.Replace(str, "([a-z0-9])([A-Z])", "$1_$2").ToLowerInvariant(); + } + + /// + /// Converts a snake_case string to camelCase. + /// Example: "search_method" -> "searchMethod" + /// + /// The snake_case string to convert + /// The camelCase equivalent, or original string if null/empty or no underscores + public static string ToCamelCase(string str) + { + if (string.IsNullOrEmpty(str) || !str.Contains("_")) + return str; + + var parts = str.Split('_'); + if (parts.Length == 0) + return str; + + // First part stays lowercase, rest get capitalized + var first = parts[0]; + var rest = string.Concat(parts.Skip(1).Select(part => + string.IsNullOrEmpty(part) ? "" : char.ToUpperInvariant(part[0]) + part.Substring(1))); + + return first + rest; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta new file mode 100644 index 000000000..62b2fcbfc --- /dev/null +++ b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f22b312318ade42c4bb6b5dfddacecfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ToolParams.cs b/MCPForUnity/Editor/Helpers/ToolParams.cs new file mode 100644 index 000000000..681ae4890 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ToolParams.cs @@ -0,0 +1,179 @@ +using Newtonsoft.Json.Linq; +using System; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified parameter validation and extraction wrapper for MCP tools. + /// Eliminates repetitive IsNullOrEmpty checks and provides consistent error messages. + /// + public class ToolParams + { + private readonly JObject _params; + + public ToolParams(JObject @params) + { + _params = @params ?? throw new ArgumentNullException(nameof(@params)); + } + + /// + /// Get required string parameter. Returns ErrorResponse if missing or empty. + /// + public Result GetRequired(string key, string errorMessage = null) + { + var value = GetString(key); + if (string.IsNullOrEmpty(value)) + { + return Result.Error( + errorMessage ?? $"'{key}' parameter is required." + ); + } + return Result.Success(value); + } + + /// + /// Get optional string parameter with default value. + /// Supports both snake_case and camelCase automatically. + /// + public string Get(string key, string defaultValue = null) + { + return GetString(key) ?? defaultValue; + } + + /// + /// Get optional int parameter. + /// + public int? GetInt(string key, int? defaultValue = null) + { + var str = GetString(key); + if (string.IsNullOrEmpty(str)) return defaultValue; + return int.TryParse(str, out var result) ? result : defaultValue; + } + + /// + /// Get optional bool parameter. + /// Supports both snake_case and camelCase automatically. + /// + public bool GetBool(string key, bool defaultValue = false) + { + return ParamCoercion.CoerceBool(GetToken(key), defaultValue); + } + + /// + /// Get optional float parameter. + /// + public float? GetFloat(string key, float? defaultValue = null) + { + var str = GetString(key); + if (string.IsNullOrEmpty(str)) return defaultValue; + return float.TryParse(str, out var result) ? result : defaultValue; + } + + /// + /// Check if parameter exists (even if null). + /// Supports both snake_case and camelCase automatically. + /// + public bool Has(string key) + { + return GetToken(key) != null; + } + + /// + /// Get raw JToken for complex types. + /// Supports both snake_case and camelCase automatically. + /// + public JToken GetRaw(string key) + { + return GetToken(key); + } + + /// + /// Get raw JToken with snake_case/camelCase fallback. + /// + private JToken GetToken(string key) + { + // Try exact match first + var token = _params[key]; + if (token != null) return token; + + // Try snake_case if camelCase was provided + var snakeKey = ToSnakeCase(key); + if (snakeKey != key) + { + token = _params[snakeKey]; + if (token != null) return token; + } + + // Try camelCase if snake_case was provided + var camelKey = ToCamelCase(key); + if (camelKey != key) + { + token = _params[camelKey]; + } + + return token; + } + + private string GetString(string key) + { + // Try exact match first + var value = _params[key]?.ToString(); + if (value != null) return value; + + // Try snake_case if camelCase was provided + var snakeKey = ToSnakeCase(key); + if (snakeKey != key) + { + value = _params[snakeKey]?.ToString(); + if (value != null) return value; + } + + // Try camelCase if snake_case was provided + var camelKey = ToCamelCase(key); + if (camelKey != key) + { + value = _params[camelKey]?.ToString(); + } + + return value; + } + + private static string ToSnakeCase(string str) => StringCaseUtility.ToSnakeCase(str); + + private static string ToCamelCase(string str) => StringCaseUtility.ToCamelCase(str); + } + + /// + /// Result type for operations that can fail with an error message. + /// + public class Result + { + public bool IsSuccess { get; } + public T Value { get; } + public string ErrorMessage { get; } + + private Result(bool isSuccess, T value, string errorMessage) + { + IsSuccess = isSuccess; + Value = value; + ErrorMessage = errorMessage; + } + + public static Result Success(T value) => new Result(true, value, null); + public static Result Error(string errorMessage) => new Result(false, default, errorMessage); + + /// + /// Get value or return ErrorResponse. + /// + public object GetOrError(out T value) + { + if (IsSuccess) + { + value = Value; + return null; + } + value = default; + return new ErrorResponse(ErrorMessage); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ToolParams.cs.meta b/MCPForUnity/Editor/Helpers/ToolParams.cs.meta new file mode 100644 index 000000000..e335bb468 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ToolParams.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 404b09ea3e2714e1babd16f5705ac788 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpClient.cs b/MCPForUnity/Editor/Models/McpClient.cs index 8cb9e724c..832bb8a23 100644 --- a/MCPForUnity/Editor/Models/McpClient.cs +++ b/MCPForUnity/Editor/Models/McpClient.cs @@ -34,7 +34,7 @@ public string GetStatusDisplayString() McpStatus.NoResponse => "No Response", McpStatus.UnsupportedOS => "Unsupported OS", McpStatus.MissingConfig => "Missing MCPForUnity Config", - McpStatus.Error => configStatus.StartsWith("Error:") ? configStatus : "Error", + McpStatus.Error => configStatus?.StartsWith("Error:") == true ? configStatus : "Error", _ => "Unknown", }; } diff --git a/MCPForUnity/Editor/Resources/Tests/GetTests.cs b/MCPForUnity/Editor/Resources/Tests/GetTests.cs index e535208e5..4fe48c727 100644 --- a/MCPForUnity/Editor/Resources/Tests/GetTests.cs +++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services; @@ -9,20 +10,44 @@ namespace MCPForUnity.Editor.Resources.Tests { /// - /// Provides access to Unity tests from the Test Framework. + /// Provides access to Unity tests from the Test Framework with pagination and filtering support. /// This is a read-only resource that can be queried by MCP clients. + /// + /// Parameters: + /// - mode (optional): Filter by "EditMode" or "PlayMode" + /// - filter (optional): Filter test names by pattern (case-insensitive contains) + /// - page_size (optional): Number of tests per page (default: 50, max: 200) + /// - cursor (optional): 0-based cursor for pagination + /// - page_number (optional): 1-based page number (converted to cursor) /// [McpForUnityResource("get_tests")] public static class GetTests { + private const int DEFAULT_PAGE_SIZE = 50; + private const int MAX_PAGE_SIZE = 200; + public static async Task HandleCommand(JObject @params) { - McpLog.Info("[GetTests] Retrieving tests for all modes"); - IReadOnlyList> result; + // Parse mode filter + TestMode? modeFilter = null; + string modeStr = @params?["mode"]?.ToString(); + if (!string.IsNullOrEmpty(modeStr)) + { + if (!ModeParser.TryParse(modeStr, out modeFilter, out var parseError)) + { + return new ErrorResponse(parseError); + } + } + + // Parse name filter + string nameFilter = @params?["filter"]?.ToString(); + McpLog.Info($"[GetTests] Retrieving tests (mode={modeFilter?.ToString() ?? "all"}, filter={nameFilter ?? "none"})"); + + IReadOnlyList> allTests; try { - result = await MCPServiceLocator.Tests.GetTestsAsync(mode: null).ConfigureAwait(true); + allTests = await MCPServiceLocator.Tests.GetTestsAsync(modeFilter).ConfigureAwait(true); } catch (Exception ex) { @@ -30,23 +55,68 @@ public static async Task HandleCommand(JObject @params) return new ErrorResponse("Failed to retrieve tests"); } - string message = $"Retrieved {result.Count} tests"; + // Apply name filter if provided and convert to List for pagination + List> filteredTests; + if (!string.IsNullOrEmpty(nameFilter)) + { + filteredTests = allTests + .Where(t => + (t.ContainsKey("name") && t["name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) || + (t.ContainsKey("full_name") && t["full_name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) + ) + .ToList(); + } + else + { + filteredTests = allTests.ToList(); + } + + // Clamp page_size before parsing pagination to ensure cursor is computed correctly + int requestedPageSize = ParamCoercion.CoerceInt( + @params?["page_size"] ?? @params?["pageSize"], + DEFAULT_PAGE_SIZE + ); + int clampedPageSize = System.Math.Min(requestedPageSize, MAX_PAGE_SIZE); + if (clampedPageSize <= 0) clampedPageSize = DEFAULT_PAGE_SIZE; + + // Create modified params with clamped page_size for cursor calculation + var paginationParams = new JObject(@params); + paginationParams["page_size"] = clampedPageSize; + + // Parse pagination with clamped page size + var pagination = PaginationRequest.FromParams(paginationParams, DEFAULT_PAGE_SIZE); + + // Create paginated response + var response = PaginationResponse>.Create(filteredTests, pagination); - return new SuccessResponse(message, result); + string message = !string.IsNullOrEmpty(nameFilter) + ? $"Retrieved {response.Items.Count} of {response.TotalCount} tests matching '{nameFilter}' (cursor {response.Cursor})" + : $"Retrieved {response.Items.Count} of {response.TotalCount} tests (cursor {response.Cursor})"; + + return new SuccessResponse(message, response); } } /// + /// DEPRECATED: Use get_tests with mode parameter instead. /// Provides access to Unity tests for a specific mode (EditMode or PlayMode). /// This is a read-only resource that can be queried by MCP clients. + /// + /// Parameters: + /// - mode (required): "EditMode" or "PlayMode" + /// - filter (optional): Filter test names by pattern (case-insensitive contains) + /// - page_size (optional): Number of tests per page (default: 50, max: 200) + /// - cursor (optional): 0-based cursor for pagination /// [McpForUnityResource("get_tests_for_mode")] public static class GetTestsForMode { + private const int DEFAULT_PAGE_SIZE = 50; + private const int MAX_PAGE_SIZE = 200; + public static async Task HandleCommand(JObject @params) { - IReadOnlyList> result; - string modeStr = @params["mode"]?.ToString(); + string modeStr = @params?["mode"]?.ToString(); if (string.IsNullOrEmpty(modeStr)) { return new ErrorResponse("'mode' parameter is required"); @@ -57,11 +127,15 @@ public static async Task HandleCommand(JObject @params) return new ErrorResponse(parseError); } - McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}"); + // Parse name filter + string nameFilter = @params?["filter"]?.ToString(); + McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value} (filter={nameFilter ?? "none"})"); + + IReadOnlyList> allTests; try { - result = await MCPServiceLocator.Tests.GetTestsAsync(parsedMode).ConfigureAwait(true); + allTests = await MCPServiceLocator.Tests.GetTestsAsync(parsedMode).ConfigureAwait(true); } catch (Exception ex) { @@ -69,8 +143,45 @@ public static async Task HandleCommand(JObject @params) return new ErrorResponse("Failed to retrieve tests"); } - string message = $"Retrieved {result.Count} {parsedMode.Value} tests"; - return new SuccessResponse(message, result); + // Apply name filter if provided and convert to List for pagination + List> filteredTests; + if (!string.IsNullOrEmpty(nameFilter)) + { + filteredTests = allTests + .Where(t => + (t.ContainsKey("name") && t["name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) || + (t.ContainsKey("full_name") && t["full_name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) + ) + .ToList(); + } + else + { + filteredTests = allTests.ToList(); + } + + // Clamp page_size before parsing pagination to ensure cursor is computed correctly + int requestedPageSize = ParamCoercion.CoerceInt( + @params?["page_size"] ?? @params?["pageSize"], + DEFAULT_PAGE_SIZE + ); + int clampedPageSize = System.Math.Min(requestedPageSize, MAX_PAGE_SIZE); + if (clampedPageSize <= 0) clampedPageSize = DEFAULT_PAGE_SIZE; + + // Create modified params with clamped page_size for cursor calculation + var paginationParams = new JObject(@params); + paginationParams["page_size"] = clampedPageSize; + + // Parse pagination with clamped page size + var pagination = PaginationRequest.FromParams(paginationParams, DEFAULT_PAGE_SIZE); + + // Create paginated response + var response = PaginationResponse>.Create(filteredTests, pagination); + + string message = nameFilter != null + ? $"Retrieved {response.Items.Count} of {response.TotalCount} {parsedMode.Value} tests matching '{nameFilter}'" + : $"Retrieved {response.Items.Count} of {response.TotalCount} {parsedMode.Value} tests"; + + return new SuccessResponse(message, response); } } diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs index 4057adfb6..04583e7de 100644 --- a/MCPForUnity/Editor/Services/BridgeControlService.cs +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -24,7 +24,7 @@ public BridgeControlService() private TransportMode ResolvePreferredMode() { - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; _preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio; return _preferredMode; } diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs new file mode 100644 index 000000000..86b5df95f --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -0,0 +1,320 @@ +using System; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Centralized cache for frequently-read EditorPrefs values. + /// Reduces scattered EditorPrefs.Get* calls and provides change notification. + /// + /// Usage: + /// var config = EditorConfigurationCache.Instance; + /// if (config.UseHttpTransport) { ... } + /// config.OnConfigurationChanged += (key) => { /* refresh UI */ }; + /// + public class EditorConfigurationCache + { + private static EditorConfigurationCache _instance; + private static readonly object _lock = new object(); + + /// + /// Singleton instance. Thread-safe lazy initialization. + /// + public static EditorConfigurationCache Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new EditorConfigurationCache(); + } + } + } + return _instance; + } + } + + /// + /// Event fired when any cached configuration value changes. + /// The string parameter is the EditorPrefKeys constant name that changed. + /// + public event Action OnConfigurationChanged; + + // Cached values - most frequently read + private bool _useHttpTransport; + private bool _debugLogs; + private bool _useBetaServer; + private bool _devModeForceServerRefresh; + private string _uvxPathOverride; + private string _gitUrlOverride; + private string _httpBaseUrl; + private string _claudeCliPathOverride; + private string _httpTransportScope; + private int _unitySocketPort; + + /// + /// Whether to use HTTP transport (true) or Stdio transport (false). + /// Default: true + /// + public bool UseHttpTransport => _useHttpTransport; + + /// + /// Whether debug logging is enabled. + /// Default: false + /// + public bool DebugLogs => _debugLogs; + + /// + /// Whether to use the beta server channel. + /// Default: true + /// + public bool UseBetaServer => _useBetaServer; + + /// + /// Whether to force server refresh in dev mode (--no-cache --refresh). + /// Default: false + /// + public bool DevModeForceServerRefresh => _devModeForceServerRefresh; + + /// + /// Custom path override for uvx executable. + /// Default: empty string (auto-detect) + /// + public string UvxPathOverride => _uvxPathOverride; + + /// + /// Custom Git URL override for server installation. + /// Default: empty string (use default) + /// + public string GitUrlOverride => _gitUrlOverride; + + /// + /// HTTP base URL for the MCP server. + /// Default: empty string + /// + public string HttpBaseUrl => _httpBaseUrl; + + /// + /// Custom path override for Claude CLI executable. + /// Default: empty string (auto-detect) + /// + public string ClaudeCliPathOverride => _claudeCliPathOverride; + + /// + /// HTTP transport scope: "local" or "remote". + /// Default: empty string + /// + public string HttpTransportScope => _httpTransportScope; + + /// + /// Unity socket port for Stdio transport. + /// Default: 0 (auto-assign) + /// + public int UnitySocketPort => _unitySocketPort; + + private EditorConfigurationCache() + { + Refresh(); + } + + /// + /// Refresh all cached values from EditorPrefs. + /// Call this after bulk EditorPrefs changes or domain reload. + /// + public void Refresh() + { + _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + _useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); + _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); + _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + } + + /// + /// Set UseHttpTransport and update cache + EditorPrefs atomically. + /// + public void SetUseHttpTransport(bool value) + { + if (_useHttpTransport != value) + { + _useHttpTransport = value; + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, value); + OnConfigurationChanged?.Invoke(nameof(UseHttpTransport)); + } + } + + /// + /// Set DebugLogs and update cache + EditorPrefs atomically. + /// + public void SetDebugLogs(bool value) + { + if (_debugLogs != value) + { + _debugLogs = value; + EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, value); + OnConfigurationChanged?.Invoke(nameof(DebugLogs)); + } + } + + /// + /// Set UseBetaServer and update cache + EditorPrefs atomically. + /// + public void SetUseBetaServer(bool value) + { + if (_useBetaServer != value) + { + _useBetaServer = value; + EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, value); + OnConfigurationChanged?.Invoke(nameof(UseBetaServer)); + } + } + + /// + /// Set DevModeForceServerRefresh and update cache + EditorPrefs atomically. + /// + public void SetDevModeForceServerRefresh(bool value) + { + if (_devModeForceServerRefresh != value) + { + _devModeForceServerRefresh = value; + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, value); + OnConfigurationChanged?.Invoke(nameof(DevModeForceServerRefresh)); + } + } + + /// + /// Set UvxPathOverride and update cache + EditorPrefs atomically. + /// + public void SetUvxPathOverride(string value) + { + value = value ?? string.Empty; + if (_uvxPathOverride != value) + { + _uvxPathOverride = value; + EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, value); + OnConfigurationChanged?.Invoke(nameof(UvxPathOverride)); + } + } + + /// + /// Set GitUrlOverride and update cache + EditorPrefs atomically. + /// + public void SetGitUrlOverride(string value) + { + value = value ?? string.Empty; + if (_gitUrlOverride != value) + { + _gitUrlOverride = value; + EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, value); + OnConfigurationChanged?.Invoke(nameof(GitUrlOverride)); + } + } + + /// + /// Set HttpBaseUrl and update cache + EditorPrefs atomically. + /// + public void SetHttpBaseUrl(string value) + { + value = value ?? string.Empty; + if (_httpBaseUrl != value) + { + _httpBaseUrl = value; + EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, value); + OnConfigurationChanged?.Invoke(nameof(HttpBaseUrl)); + } + } + + /// + /// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically. + /// + public void SetClaudeCliPathOverride(string value) + { + value = value ?? string.Empty; + if (_claudeCliPathOverride != value) + { + _claudeCliPathOverride = value; + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, value); + OnConfigurationChanged?.Invoke(nameof(ClaudeCliPathOverride)); + } + } + + /// + /// Set HttpTransportScope and update cache + EditorPrefs atomically. + /// + public void SetHttpTransportScope(string value) + { + value = value ?? string.Empty; + if (_httpTransportScope != value) + { + _httpTransportScope = value; + EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, value); + OnConfigurationChanged?.Invoke(nameof(HttpTransportScope)); + } + } + + /// + /// Set UnitySocketPort and update cache + EditorPrefs atomically. + /// + public void SetUnitySocketPort(int value) + { + if (_unitySocketPort != value) + { + _unitySocketPort = value; + EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, value); + OnConfigurationChanged?.Invoke(nameof(UnitySocketPort)); + } + } + + /// + /// Force refresh of a single cached value from EditorPrefs. + /// Useful when external code modifies EditorPrefs directly. + /// + public void InvalidateKey(string keyName) + { + switch (keyName) + { + case nameof(UseHttpTransport): + _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + break; + case nameof(DebugLogs): + _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + break; + case nameof(UseBetaServer): + _useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + break; + case nameof(DevModeForceServerRefresh): + _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + break; + case nameof(UvxPathOverride): + _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + break; + case nameof(GitUrlOverride): + _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); + break; + case nameof(HttpBaseUrl): + _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + break; + case nameof(ClaudeCliPathOverride): + _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + break; + case nameof(HttpTransportScope): + _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); + break; + case nameof(UnitySocketPort): + _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + break; + } + OnConfigurationChanged?.Invoke(keyName); + } + } +} diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta new file mode 100644 index 000000000..a6416b44e --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4a183ac9b63c408886bce40ae58f462 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs index 2f5ef681b..b0ffd3ecc 100644 --- a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs @@ -60,7 +60,7 @@ private static void OnAfterAssemblyReload() try { // Only resume HTTP if it is still the selected transport. - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; resume = useHttp && EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false); if (resume) { diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs index 2cf33f8f4..9b4bd6143 100644 --- a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs +++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs @@ -43,7 +43,7 @@ private static void OnEditorQuitting() // 2) Stop local HTTP server if it was Unity-managed (best-effort). try { - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; string scope = string.Empty; try { scope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); } catch { } diff --git a/MCPForUnity/Editor/Services/Server.meta b/MCPForUnity/Editor/Services/Server.meta new file mode 100644 index 000000000..e1e1dd449 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bb072befc9fe4242a501f46dce3fea1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/IPidFileManager.cs b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs new file mode 100644 index 000000000..b9bd74be9 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs @@ -0,0 +1,94 @@ +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for managing PID files and handshake state for the local HTTP server. + /// Handles persistence of server process information across Unity domain reloads. + /// + public interface IPidFileManager + { + /// + /// Gets the directory where PID files are stored. + /// + /// Path to the PID file directory + string GetPidDirectory(); + + /// + /// Gets the path to the PID file for a specific port. + /// + /// The port number + /// Full path to the PID file + string GetPidFilePath(int port); + + /// + /// Attempts to read the PID from a PID file. + /// + /// Path to the PID file + /// Output: the process ID if found + /// True if a valid PID was read + bool TryReadPid(string pidFilePath, out int pid); + + /// + /// Attempts to extract the port number from a PID file path. + /// + /// Path to the PID file + /// Output: the port number + /// True if the port was extracted successfully + bool TryGetPortFromPidFilePath(string pidFilePath, out int port); + + /// + /// Deletes a PID file. + /// + /// Path to the PID file to delete + void DeletePidFile(string pidFilePath); + + /// + /// Stores the handshake information (PID file path and instance token) in EditorPrefs. + /// + /// Path to the PID file + /// Unique instance token for the server + void StoreHandshake(string pidFilePath, string instanceToken); + + /// + /// Attempts to retrieve stored handshake information from EditorPrefs. + /// + /// Output: stored PID file path + /// Output: stored instance token + /// True if valid handshake information was found + bool TryGetHandshake(out string pidFilePath, out string instanceToken); + + /// + /// Stores PID tracking information in EditorPrefs. + /// + /// The process ID + /// The port number + /// Optional hash of the command arguments + void StoreTracking(int pid, int port, string argsHash = null); + + /// + /// Attempts to retrieve a stored PID for the expected port. + /// Validates that the stored information is still valid (within 6-hour window). + /// + /// The expected port number + /// Output: the stored process ID + /// True if a valid stored PID was found + bool TryGetStoredPid(int expectedPort, out int pid); + + /// + /// Gets the stored args hash for the tracked server. + /// + /// The stored args hash, or empty string if not found + string GetStoredArgsHash(); + + /// + /// Clears all PID tracking information from EditorPrefs. + /// + void ClearTracking(); + + /// + /// Computes a short hash of the input string for fingerprinting. + /// + /// The input string + /// A short hash string (16 hex characters) + string ComputeShortHash(string input); + } +} diff --git a/MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta new file mode 100644 index 000000000..b70da866b --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f4a4c5d093da74ce79fb29a0670a58a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/IProcessDetector.cs b/MCPForUnity/Editor/Services/Server/IProcessDetector.cs new file mode 100644 index 000000000..886e29dcc --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IProcessDetector.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for platform-specific process inspection operations. + /// Provides methods to detect MCP server processes, query process command lines, + /// and find processes listening on specific ports. + /// + public interface IProcessDetector + { + /// + /// Determines if a process looks like an MCP server process based on its command line. + /// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc. + /// + /// The process ID to check + /// True if the process appears to be an MCP server + bool LooksLikeMcpServerProcess(int pid); + + /// + /// Attempts to get the command line arguments for a Unix process. + /// + /// The process ID + /// Output: normalized (lowercase, whitespace removed) command line args + /// True if the command line was retrieved successfully + bool TryGetProcessCommandLine(int pid, out string argsLower); + + /// + /// Gets the process IDs of all processes listening on a specific TCP port. + /// + /// The port number to check + /// List of process IDs listening on the port + List GetListeningProcessIdsForPort(int port); + + /// + /// Gets the current Unity Editor process ID safely. + /// + /// The current process ID, or -1 if it cannot be determined + int GetCurrentProcessId(); + + /// + /// Checks if a process exists on Unix systems. + /// + /// The process ID to check + /// True if the process exists + bool ProcessExists(int pid); + + /// + /// Normalizes a string for matching by removing whitespace and converting to lowercase. + /// + /// The input string + /// Normalized string for matching + string NormalizeForMatch(string input); + } +} diff --git a/MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta b/MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta new file mode 100644 index 000000000..6524cd209 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25f32875fb87541b69ead19c08520836 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs b/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs new file mode 100644 index 000000000..0f6e9f881 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs @@ -0,0 +1,18 @@ +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for platform-specific process termination. + /// Provides methods to terminate processes gracefully or forcefully. + /// + public interface IProcessTerminator + { + /// + /// Terminates a process using platform-appropriate methods. + /// On Unix: Tries SIGTERM first with grace period, then SIGKILL. + /// On Windows: Tries taskkill, then taskkill /F. + /// + /// The process ID to terminate + /// True if the process was terminated successfully + bool Terminate(int pid); + } +} diff --git a/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta b/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta new file mode 100644 index 000000000..c3441e502 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a55c18e08b534afa85654410da8a463 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs new file mode 100644 index 000000000..f32b1ebd5 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs @@ -0,0 +1,39 @@ +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for building uvx/server command strings. + /// Handles platform-specific command construction for starting the MCP HTTP server. + /// + public interface IServerCommandBuilder + { + /// + /// Attempts to build the command parts for starting the local HTTP server. + /// + /// Output: the executable file name (e.g., uvx path) + /// Output: the command arguments + /// Output: the full command string for display + /// Output: error message if the command cannot be built + /// True if the command was built successfully + bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error); + + /// + /// Builds the uv path from the uvx path by replacing uvx with uv. + /// + /// Path to uvx executable + /// Path to uv executable + string BuildUvPathFromUvx(string uvxPath); + + /// + /// Gets the platform-specific PATH prepend string for finding uv/uvx. + /// + /// Paths to prepend to PATH environment variable + string GetPlatformSpecificPathPrepend(); + + /// + /// Quotes a string if it contains spaces. + /// + /// The input string + /// The string, wrapped in quotes if it contains spaces + string QuoteIfNeeded(string input); + } +} diff --git a/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta b/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta new file mode 100644 index 000000000..995c58427 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12e80005e3f5b45239c48db981675ccf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs b/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs new file mode 100644 index 000000000..3a896842b --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for launching commands in platform-specific terminal windows. + /// Supports macOS Terminal, Windows cmd, and Linux terminal emulators. + /// + public interface ITerminalLauncher + { + /// + /// Creates a ProcessStartInfo for opening a terminal window with the given command. + /// Works cross-platform: macOS, Windows, and Linux. + /// + /// The command to execute in the terminal + /// A configured ProcessStartInfo for launching the terminal + ProcessStartInfo CreateTerminalProcessStartInfo(string command); + + /// + /// Gets the project root path for storing terminal scripts. + /// + /// Path to the project root directory + string GetProjectRootPath(); + } +} diff --git a/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta b/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta new file mode 100644 index 000000000..dcb86ab69 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5990e868c0cd4999858ce1c1a2defed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/PidFileManager.cs b/MCPForUnity/Editor/Services/Server/PidFileManager.cs new file mode 100644 index 000000000..eca60ee27 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/PidFileManager.cs @@ -0,0 +1,275 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Manages PID files and handshake state for the local HTTP server. + /// Handles persistence of server process information across Unity domain reloads. + /// + public class PidFileManager : IPidFileManager + { + /// + public string GetPidDirectory() + { + return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "RunState"); + } + + /// + public string GetPidFilePath(int port) + { + string dir = GetPidDirectory(); + Directory.CreateDirectory(dir); + return Path.Combine(dir, $"mcp_http_{port}.pid"); + } + + /// + public bool TryReadPid(string pidFilePath, out int pid) + { + pid = 0; + try + { + if (string.IsNullOrEmpty(pidFilePath) || !File.Exists(pidFilePath)) + { + return false; + } + + string text = File.ReadAllText(pidFilePath).Trim(); + if (int.TryParse(text, out pid)) + { + return pid > 0; + } + + // Best-effort: tolerate accidental extra whitespace/newlines. + var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (int.TryParse(firstLine, out pid)) + { + return pid > 0; + } + + pid = 0; + return false; + } + catch + { + pid = 0; + return false; + } + } + + /// + public bool TryGetPortFromPidFilePath(string pidFilePath, out int port) + { + port = 0; + if (string.IsNullOrEmpty(pidFilePath)) + { + return false; + } + + try + { + string fileName = Path.GetFileNameWithoutExtension(pidFilePath); + if (string.IsNullOrEmpty(fileName)) + { + return false; + } + + const string prefix = "mcp_http_"; + if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string portText = fileName.Substring(prefix.Length); + return int.TryParse(portText, out port) && port > 0; + } + catch + { + port = 0; + return false; + } + } + + /// + public void DeletePidFile(string pidFilePath) + { + try + { + if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath)) + { + File.Delete(pidFilePath); + } + } + catch { } + } + + /// + public void StoreHandshake(string pidFilePath, string instanceToken) + { + try + { + if (!string.IsNullOrEmpty(pidFilePath)) + { + EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath); + } + } + catch { } + + try + { + if (!string.IsNullOrEmpty(instanceToken)) + { + EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken); + } + } + catch { } + } + + /// + public bool TryGetHandshake(out string pidFilePath, out string instanceToken) + { + pidFilePath = null; + instanceToken = null; + try + { + pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty); + instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty); + if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken)) + { + pidFilePath = null; + instanceToken = null; + return false; + } + return true; + } + catch + { + pidFilePath = null; + instanceToken = null; + return false; + } + } + + /// + public void StoreTracking(int pid, int port, string argsHash = null) + { + try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { } + try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { } + try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { } + try + { + if (!string.IsNullOrEmpty(argsHash)) + { + EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); + } + } + catch { } + } + + /// + public bool TryGetStoredPid(int expectedPort, out int pid) + { + pid = 0; + try + { + int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0); + int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0); + string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty); + + if (storedPid <= 0 || storedPort != expectedPort) + { + return false; + } + + // Only trust the stored PID for a short window to avoid PID reuse issues. + // (We still verify the PID is listening on the expected port before killing.) + if (!string.IsNullOrEmpty(storedUtc) + && DateTime.TryParse(storedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var startedAt)) + { + if ((DateTime.UtcNow - startedAt) > TimeSpan.FromHours(6)) + { + return false; + } + } + + pid = storedPid; + return true; + } + catch + { + return false; + } + } + + /// + public string GetStoredArgsHash() + { + try + { + return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty); + } + catch + { + return string.Empty; + } + } + + /// + public void ClearTracking() + { + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { } + } + + /// + public string ComputeShortHash(string input) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + try + { + using var sha = SHA256.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input); + byte[] hash = sha.ComputeHash(bytes); + // 8 bytes => 16 hex chars is plenty as a stable fingerprint for our purposes. + var sb = new StringBuilder(16); + for (int i = 0; i < 8 && i < hash.Length; i++) + { + sb.Append(hash[i].ToString("x2")); + } + return sb.ToString(); + } + catch + { + return string.Empty; + } + } + + private static string GetProjectRootPath() + { + try + { + // Application.dataPath is "...//Assets" + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + catch + { + return Application.dataPath; + } + } + } +} diff --git a/MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta b/MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta new file mode 100644 index 000000000..d36102502 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57875f281fda94a4ea17cb74d4b13378 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/ProcessDetector.cs b/MCPForUnity/Editor/Services/Server/ProcessDetector.cs new file mode 100644 index 000000000..b553cb3c7 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ProcessDetector.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Platform-specific process inspection for detecting MCP server processes. + /// + public class ProcessDetector : IProcessDetector + { + /// + public string NormalizeForMatch(string input) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + var sb = new StringBuilder(input.Length); + foreach (char c in input) + { + if (char.IsWhiteSpace(c)) continue; + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } + + /// + public int GetCurrentProcessId() + { + try { return System.Diagnostics.Process.GetCurrentProcess().Id; } + catch { return -1; } + } + + /// + public bool ProcessExists(int pid) + { + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // On Windows, use tasklist to check if process exists + bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000); + string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant(); + return ok && combined.Contains(pid.ToString()); + } + + // Unix: ps exits non-zero when PID is not found. + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + ExecPath.TryRun(psPath, $"-p {pid} -o pid=", Application.dataPath, out var psStdout, out var psStderr, 2000); + string combined2 = ((psStdout ?? string.Empty) + "\n" + (psStderr ?? string.Empty)).Trim(); + return !string.IsNullOrEmpty(combined2) && combined2.Any(char.IsDigit); + } + catch + { + return true; // Assume it exists if we cannot verify. + } + } + + /// + public bool TryGetProcessCommandLine(int pid, out string argsLower) + { + argsLower = string.Empty; + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Windows: use wmic to get command line + ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000); + string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)); + if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.ToLowerInvariant().Contains("commandline=")) + { + argsLower = NormalizeForMatch(wmicOut ?? string.Empty); + return true; + } + return false; + } + + // Unix: ps -p pid -ww -o args= + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + + bool ok = ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000); + if (!ok && string.IsNullOrWhiteSpace(stdout)) + { + return false; + } + string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim(); + if (string.IsNullOrEmpty(combined)) return false; + // Normalize for matching to tolerate ps wrapping/newlines. + argsLower = NormalizeForMatch(combined); + return true; + } + catch + { + return false; + } + } + + /// + public List GetListeningProcessIdsForPort(int port) + { + var results = new List(); + try + { + string stdout, stderr; + bool success; + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Run netstat -ano directly (without findstr) and filter in C#. + // Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found, + // which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success. + success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr); + + // Process stdout regardless of success flag - netstat might still produce valid output + if (!string.IsNullOrEmpty(stdout)) + { + string portSuffix = $":{port}"; + var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + // Windows netstat format: Proto Local Address Foreign Address State PID + // Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345 + if (line.Contains("LISTENING") && line.Contains(portSuffix)) + { + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + // Verify the local address column actually ends with :{port} + // parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID + if (parts.Length >= 5) + { + string localAddr = parts[1]; + if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int parsedPid)) + { + results.Add(parsedPid); + } + } + } + } + } + } + else + { + // lsof: only return LISTENers (avoids capturing random clients) + // Use /usr/sbin/lsof directly as it might not be in PATH for Unity + string lsofPath = "/usr/sbin/lsof"; + if (!File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback + + // -nP: avoid DNS/service name lookups; faster and less error-prone + success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr); + if (success && !string.IsNullOrWhiteSpace(stdout)) + { + var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var pidString in pidStrings) + { + if (int.TryParse(pidString.Trim(), out int parsedPid)) + { + results.Add(parsedPid); + } + } + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Error checking port {port}: {ex.Message}"); + } + return results.Distinct().ToList(); + } + + /// + public bool LooksLikeMcpServerProcess(int pid) + { + try + { + // Windows best-effort: First check process name with tasklist, then try to get command line with wmic + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Step 1: Check if process name matches known server executables + ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000); + string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant(); + + // Check for common process names + bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe"); + if (!isPythonOrUv) + { + return false; + } + + // Step 2: Try to get command line with wmic for better validation + ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000); + string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant(); + string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty); + + // If we can see the command line, validate it's our server + if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline=")) + { + bool mentionsMcp = wmicCompact.Contains("mcp-for-unity") + || wmicCompact.Contains("mcp_for_unity") + || wmicCompact.Contains("mcpforunity") + || wmicCompact.Contains("mcpforunityserver"); + bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http")); + bool mentionsUvicorn = wmicCombined.Contains("uvicorn"); + + if (mentionsMcp || mentionsTransport || mentionsUvicorn) + { + return true; + } + } + + // Fall back to just checking for python/uv processes if wmic didn't give us details + // This is less precise but necessary for cases where wmic access is restricted + return isPythonOrUv; + } + + // macOS/Linux: ps -p pid -ww -o comm= -o args= + // Use -ww to avoid truncating long command lines (important for reliably spotting 'mcp-for-unity'). + // Use an absolute ps path to avoid relying on PATH inside the Unity Editor process. + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + // Important: ExecPath.TryRun returns false when exit code != 0, but ps output can still be useful. + // Always parse stdout/stderr regardless of exit code to avoid false negatives. + ExecPath.TryRun(psPath, $"-p {pid} -ww -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000); + string raw = ((psOut ?? string.Empty) + "\n" + (psErr ?? string.Empty)).Trim(); + string s = raw.ToLowerInvariant(); + string sCompact = NormalizeForMatch(raw); + if (!string.IsNullOrEmpty(s)) + { + bool mentionsMcp = sCompact.Contains("mcp-for-unity") + || sCompact.Contains("mcp_for_unity") + || sCompact.Contains("mcpforunity"); + + // If it explicitly mentions the server package/entrypoint, that is sufficient. + // Note: Check before Unity exclusion since "mcp-for-unity" contains "unity". + if (mentionsMcp) + { + return true; + } + + // Explicitly never kill Unity / Unity Hub processes + // Note: explicit !mentionsMcp is defensive; we already return early for mentionsMcp above. + if (s.Contains("unityhub") || s.Contains("unity hub") || (s.Contains("unity") && !mentionsMcp)) + { + return false; + } + + // Positive indicators + bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx "); + bool mentionsUv = s.Contains("uv ") || s.Contains("/uv"); + bool mentionsPython = s.Contains("python"); + bool mentionsUvicorn = s.Contains("uvicorn"); + bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http")); + + // Accept if it looks like uv/uvx/python launching our server package/entrypoint + if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport) + { + return true; + } + } + } + catch { } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta b/MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta new file mode 100644 index 000000000..6cd7c465a --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4df6fa24a35d74d1cb9b67e40e50b45d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs b/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs new file mode 100644 index 000000000..7e803b11b --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Platform-specific process termination for stopping MCP server processes. + /// + public class ProcessTerminator : IProcessTerminator + { + private readonly IProcessDetector _processDetector; + + /// + /// Creates a new ProcessTerminator with the specified process detector. + /// + /// Process detector for checking process existence + public ProcessTerminator(IProcessDetector processDetector) + { + _processDetector = processDetector ?? throw new ArgumentNullException(nameof(processDetector)); + } + + /// + public bool Terminate(int pid) + { + // CRITICAL: Validate PID before any kill operation. + // On Unix, kill(-1) kills ALL processes the user can signal! + // On Unix, kill(0) signals all processes in the process group. + // PID 1 is init/launchd and must never be killed. + // Only positive PIDs > 1 are valid for targeted termination. + if (pid <= 1) + { + return false; + } + + // Never kill the current Unity process + int currentPid = _processDetector.GetCurrentProcessId(); + if (currentPid > 0 && pid == currentPid) + { + return false; + } + + try + { + string stdout, stderr; + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // taskkill without /F first; fall back to /F if needed. + bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr); + if (!ok) + { + ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr); + } + return ok; + } + else + { + // Try a graceful termination first, then escalate if the process is still alive. + // Note: `kill -15` can succeed (exit 0) even if the process takes time to exit, + // so we verify and only escalate when needed. + string killPath = "/bin/kill"; + if (!File.Exists(killPath)) killPath = "kill"; + ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr); + + // Wait briefly for graceful shutdown. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8); + while (DateTime.UtcNow < deadline) + { + if (!_processDetector.ProcessExists(pid)) + { + return true; + } + System.Threading.Thread.Sleep(100); + } + + // Escalate. + ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr); + return !_processDetector.ProcessExists(pid); + } + } + catch (Exception ex) + { + McpLog.Error($"Error killing process {pid}: {ex.Message}"); + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta b/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta new file mode 100644 index 000000000..7961fd895 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 900df88b4d0844704af9cb47633d44a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs new file mode 100644 index 000000000..47791aa99 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Builds uvx/server command strings for starting the MCP HTTP server. + /// Handles platform-specific command construction. + /// + public class ServerCommandBuilder : IServerCommandBuilder + { + /// + public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error) + { + fileName = null; + arguments = null; + displayCommand = null; + error = null; + + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + if (!useHttpTransport) + { + error = "HTTP transport is disabled. Enable it in the MCP For Unity window first."; + return false; + } + + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; + return false; + } + + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrEmpty(uvxPath)) + { + error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings."; + return false; + } + + // 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; + bool projectScopedTools = EditorPrefs.GetBool( + EditorPrefKeys.ProjectScopedToolsLocalHttp, + true + ); + string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty; + + // Use centralized helper for beta server / prerelease args + string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); + + string args = string.IsNullOrEmpty(fromArgs) + ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}" + : $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}"; + + fileName = uvxPath; + arguments = args; + displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}"; + return true; + } + + /// + public string BuildUvPathFromUvx(string uvxPath) + { + if (string.IsNullOrWhiteSpace(uvxPath)) + { + return uvxPath; + } + + string directory = Path.GetDirectoryName(uvxPath); + string extension = Path.GetExtension(uvxPath); + string uvFileName = "uv" + extension; + + return string.IsNullOrEmpty(directory) + ? uvFileName + : Path.Combine(directory, uvFileName); + } + + /// + public string GetPlatformSpecificPathPrepend() + { + if (Application.platform == RuntimePlatform.OSXEditor) + { + return string.Join(Path.PathSeparator.ToString(), new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + + if (Application.platform == RuntimePlatform.LinuxEditor) + { + return string.Join(Path.PathSeparator.ToString(), new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return string.Join(Path.PathSeparator.ToString(), new[] + { + !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null, + !string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null + }.Where(p => !string.IsNullOrEmpty(p)).ToArray()); + } + + return null; + } + + /// + public string QuoteIfNeeded(string input) + { + if (string.IsNullOrEmpty(input)) return input; + return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input; + } + + /// + /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1) + /// + private static bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url)) return false; + + try + { + var uri = new Uri(url); + string host = uri.Host.ToLower(); + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; + } + catch + { + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta new file mode 100644 index 000000000..8a58c647d --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db917800a5c2948088ede8a5d230b56e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs b/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs new file mode 100644 index 000000000..fd8bd5d33 --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Launches commands in platform-specific terminal windows. + /// Supports macOS Terminal, Windows cmd, and Linux terminal emulators. + /// + public class TerminalLauncher : ITerminalLauncher + { + /// + public string GetProjectRootPath() + { + try + { + // Application.dataPath is "...//Assets" + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + catch + { + return Application.dataPath; + } + } + + /// + public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) + { + if (string.IsNullOrWhiteSpace(command)) + throw new ArgumentException("Command cannot be empty", nameof(command)); + + command = command.Replace("\r", "").Replace("\n", ""); + +#if UNITY_EDITOR_OSX + // macOS: Avoid AppleScript (automation permission prompts). Use a .command script and open it. + string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); + Directory.CreateDirectory(scriptsDir); + string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.command"); + File.WriteAllText( + scriptPath, + "#!/bin/bash\n" + + "set -e\n" + + "clear\n" + + $"{command}\n"); + ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000); + return new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/open", + Arguments = $"-a Terminal \"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#elif UNITY_EDITOR_WIN + // Windows: Avoid brittle nested-quote escaping by writing a .cmd script and starting it in a new window. + string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); + Directory.CreateDirectory(scriptsDir); + string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.cmd"); + File.WriteAllText( + scriptPath, + "@echo off\r\n" + + "cls\r\n" + + command + "\r\n"); + return new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#else + // Linux: Try common terminal emulators + // We use bash -c to execute the command, so we must properly quote/escape for bash + // Escape single quotes for the inner bash string + string escapedCommandLinux = command.Replace("'", "'\\''"); + // Wrap the command in single quotes for bash -c + string script = $"'{escapedCommandLinux}; exec bash'"; + // Escape double quotes for the outer Process argument string + string escapedScriptForArg = script.Replace("\"", "\\\""); + string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\""; + + string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" }; + string terminalCmd = null; + + foreach (var term in terminals) + { + try + { + var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "which", + Arguments = term, + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous + if (which.ExitCode == 0) + { + terminalCmd = term; + break; + } + } + catch { } + } + + if (terminalCmd == null) + { + terminalCmd = "xterm"; // Fallback + } + + // Different terminals have different argument formats + string args; + if (terminalCmd == "gnome-terminal") + { + args = $"-- {bashCmdArgs}"; + } + else if (terminalCmd == "konsole") + { + args = $"-e {bashCmdArgs}"; + } + else if (terminalCmd == "xfce4-terminal") + { + // xfce4-terminal expects -e "command string" or -e command arg + args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\""; + } + else // xterm and others + { + args = $"-hold -e {bashCmdArgs}"; + } + + return new System.Diagnostics.ProcessStartInfo + { + FileName = terminalCmd, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true + }; +#endif + } + } +} diff --git a/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta b/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta new file mode 100644 index 000000000..ec239f76b --- /dev/null +++ b/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9693a18d706548b3aae28ea87f1ed08 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index bb512832d..d67dfada6 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -2,12 +2,10 @@ using System.IO; using System.Linq; using System.Collections.Generic; -using System.Globalization; using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Server; using UnityEditor; using UnityEngine; @@ -18,138 +16,72 @@ namespace MCPForUnity.Editor.Services /// public class ServerManagementService : IServerManagementService { - private static readonly HashSet LoggedStopDiagnosticsPids = new HashSet(); + private readonly IProcessDetector _processDetector; + private readonly IPidFileManager _pidFileManager; + private readonly IProcessTerminator _processTerminator; + private readonly IServerCommandBuilder _commandBuilder; + private readonly ITerminalLauncher _terminalLauncher; - private static string GetProjectRootPath() - { - try - { - // Application.dataPath is "...//Assets" - return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - } - catch - { - return Application.dataPath; - } - } + /// + /// Creates a new ServerManagementService with default dependencies. + /// + public ServerManagementService() : this(null, null, null, null, null) { } - private static string QuoteIfNeeded(string s) + /// + /// Creates a new ServerManagementService with injected dependencies (for testing). + /// + /// Process detector implementation (null for default) + /// PID file manager implementation (null for default) + /// Process terminator implementation (null for default) + /// Server command builder implementation (null for default) + /// Terminal launcher implementation (null for default) + public ServerManagementService( + IProcessDetector processDetector, + IPidFileManager pidFileManager = null, + IProcessTerminator processTerminator = null, + IServerCommandBuilder commandBuilder = null, + ITerminalLauncher terminalLauncher = null) { - if (string.IsNullOrEmpty(s)) return s; - return s.IndexOf(' ') >= 0 ? $"\"{s}\"" : s; + _processDetector = processDetector ?? new ProcessDetector(); + _pidFileManager = pidFileManager ?? new PidFileManager(); + _processTerminator = processTerminator ?? new ProcessTerminator(_processDetector); + _commandBuilder = commandBuilder ?? new ServerCommandBuilder(); + _terminalLauncher = terminalLauncher ?? new TerminalLauncher(); } - private static string NormalizeForMatch(string s) + private string QuoteIfNeeded(string s) { - if (string.IsNullOrEmpty(s)) return string.Empty; - var sb = new StringBuilder(s.Length); - foreach (char c in s) - { - if (char.IsWhiteSpace(c)) continue; - sb.Append(char.ToLowerInvariant(c)); - } - return sb.ToString(); + return _commandBuilder.QuoteIfNeeded(s); } - private static void ClearLocalServerPidTracking() + private string NormalizeForMatch(string s) { - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { } + return _processDetector.NormalizeForMatch(s); } - private static void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken) + private void ClearLocalServerPidTracking() { - try - { - if (!string.IsNullOrEmpty(pidFilePath)) - { - EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath); - } - } - catch { } - - try - { - if (!string.IsNullOrEmpty(instanceToken)) - { - EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken); - } - } - catch { } + _pidFileManager.ClearTracking(); } - private static bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken) + private void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken) { - pidFilePath = null; - instanceToken = null; - try - { - pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty); - instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty); - if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken)) - { - pidFilePath = null; - instanceToken = null; - return false; - } - return true; - } - catch - { - pidFilePath = null; - instanceToken = null; - return false; - } + _pidFileManager.StoreHandshake(pidFilePath, instanceToken); } - private static string GetLocalHttpServerPidDirectory() + private bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken) { - // Keep it project-scoped and out of version control. - return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "RunState"); + return _pidFileManager.TryGetHandshake(out pidFilePath, out instanceToken); } - private static string GetLocalHttpServerPidFilePath(int port) + private string GetLocalHttpServerPidFilePath(int port) { - string dir = GetLocalHttpServerPidDirectory(); - Directory.CreateDirectory(dir); - return Path.Combine(dir, $"mcp_http_{port}.pid"); + return _pidFileManager.GetPidFilePath(port); } - private static bool TryReadPidFromPidFile(string pidFilePath, out int pid) + private bool TryReadPidFromPidFile(string pidFilePath, out int pid) { - pid = 0; - try - { - if (string.IsNullOrEmpty(pidFilePath) || !File.Exists(pidFilePath)) - { - return false; - } - - string text = File.ReadAllText(pidFilePath).Trim(); - if (int.TryParse(text, out pid)) - { - return pid > 0; - } - - // Best-effort: tolerate accidental extra whitespace/newlines. - var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - if (int.TryParse(firstLine, out pid)) - { - return pid > 0; - } - - pid = 0; - return false; - } - catch - { - pid = 0; - return false; - } + return _pidFileManager.TryReadPid(pidFilePath, out pid); } private bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken) @@ -186,79 +118,19 @@ private bool TryProcessCommandLineContainsInstanceToken(int pid, string instance return false; } - private static void StoreLocalServerPidTracking(int pid, int port, string argsHash = null) + private string ComputeShortHash(string input) { - try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { } - try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { } - try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { } - try - { - if (!string.IsNullOrEmpty(argsHash)) - { - EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash); - } - else - { - EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); - } - } - catch { } + return _pidFileManager.ComputeShortHash(input); } - private static string ComputeShortHash(string input) + private bool TryGetStoredLocalServerPid(int expectedPort, out int pid) { - if (string.IsNullOrEmpty(input)) return string.Empty; - try - { - using var sha = SHA256.Create(); - byte[] bytes = Encoding.UTF8.GetBytes(input); - byte[] hash = sha.ComputeHash(bytes); - // 8 bytes => 16 hex chars is plenty as a stable fingerprint for our purposes. - var sb = new StringBuilder(16); - for (int i = 0; i < 8 && i < hash.Length; i++) - { - sb.Append(hash[i].ToString("x2")); - } - return sb.ToString(); - } - catch - { - return string.Empty; - } + return _pidFileManager.TryGetStoredPid(expectedPort, out pid); } - private static bool TryGetStoredLocalServerPid(int expectedPort, out int pid) + private string GetStoredArgsHash() { - pid = 0; - try - { - int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0); - int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0); - string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty); - - if (storedPid <= 0 || storedPort != expectedPort) - { - return false; - } - - // Only trust the stored PID for a short window to avoid PID reuse issues. - // (We still verify the PID is listening on the expected port before killing.) - if (!string.IsNullOrEmpty(storedUtc) - && DateTime.TryParse(storedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var startedAt)) - { - if ((DateTime.UtcNow - startedAt) > TimeSpan.FromHours(6)) - { - return false; - } - } - - pid = storedPid; - return true; - } - catch - { - return false; - } + return _pidFileManager.GetStoredArgsHash(); } /// @@ -347,58 +219,14 @@ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); } - private static string BuildUvPathFromUvx(string uvxPath) + private string BuildUvPathFromUvx(string uvxPath) { - if (string.IsNullOrWhiteSpace(uvxPath)) - { - return uvxPath; - } - - string directory = Path.GetDirectoryName(uvxPath); - string extension = Path.GetExtension(uvxPath); - string uvFileName = "uv" + extension; - - return string.IsNullOrEmpty(directory) - ? uvFileName - : Path.Combine(directory, uvFileName); + return _commandBuilder.BuildUvPathFromUvx(uvxPath); } private string GetPlatformSpecificPathPrepend() { - if (Application.platform == RuntimePlatform.OSXEditor) - { - return string.Join(Path.PathSeparator.ToString(), new[] - { - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin" - }); - } - - if (Application.platform == RuntimePlatform.LinuxEditor) - { - return string.Join(Path.PathSeparator.ToString(), new[] - { - "/usr/local/bin", - "/usr/bin", - "/bin" - }); - } - - if (Application.platform == RuntimePlatform.WindowsEditor) - { - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - - return string.Join(Path.PathSeparator.ToString(), new[] - { - !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null, - !string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null - }.Where(p => !string.IsNullOrEmpty(p)).ToArray()); - } - - return null; + return _commandBuilder.GetPlatformSpecificPathPrepend(); } /// @@ -475,7 +303,7 @@ public bool StartLocalHttpServer() { if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath)) { - File.Delete(pidFilePath); + DeletePidFile(pidFilePath); } } catch { } @@ -751,7 +579,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b if (listeners.Count == 0) { // Nothing is listening anymore; clear stale handshake state. - try { File.Delete(pidFilePath); } catch { } + try { DeletePidFile(pidFilePath); } catch { } ClearLocalServerPidTracking(); if (!quiet) { @@ -778,7 +606,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b if (TerminateProcess(pidFromFile)) { stoppedAny = true; - try { File.Delete(pidFilePath); } catch { } + try { DeletePidFile(pidFilePath); } catch { } ClearLocalServerPidTracking(); if (!quiet) { @@ -831,7 +659,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b if (pids.Contains(storedPid)) { string expectedHash = string.Empty; - try { expectedHash = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty); } catch { } + expectedHash = GetStoredArgsHash(); // Prefer a fingerprint match (reduces PID reuse risk). If missing (older installs), // fall back to a looser check to avoid leaving orphaned servers after domain reload. @@ -946,322 +774,39 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b } } - private static bool TryGetUnixProcessArgs(int pid, out string argsLower) + private bool TryGetUnixProcessArgs(int pid, out string argsLower) { - argsLower = string.Empty; - try - { - if (Application.platform == RuntimePlatform.WindowsEditor) - { - return false; - } - - string psPath = "/bin/ps"; - if (!File.Exists(psPath)) psPath = "ps"; - - bool ok = ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000); - if (!ok && string.IsNullOrWhiteSpace(stdout)) - { - return false; - } - string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim(); - if (string.IsNullOrEmpty(combined)) return false; - // Normalize for matching to tolerate ps wrapping/newlines. - argsLower = NormalizeForMatch(combined); - return true; - } - catch - { - return false; - } - } - - private static bool TryGetPortFromPidFilePath(string pidFilePath, out int port) - { - port = 0; - if (string.IsNullOrEmpty(pidFilePath)) - { - return false; - } - - try - { - string fileName = Path.GetFileNameWithoutExtension(pidFilePath); - if (string.IsNullOrEmpty(fileName)) - { - return false; - } - - const string prefix = "mcp_http_"; - if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - string portText = fileName.Substring(prefix.Length); - return int.TryParse(portText, out port) && port > 0; - } - catch - { - port = 0; - return false; - } + return _processDetector.TryGetProcessCommandLine(pid, out argsLower); } - private List GetListeningProcessIdsForPort(int port) + private bool TryGetPortFromPidFilePath(string pidFilePath, out int port) { - var results = new List(); - try - { - string stdout, stderr; - bool success; - - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Run netstat -ano directly (without findstr) and filter in C#. - // Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found, - // which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success. - success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr); - - // Process stdout regardless of success flag - netstat might still produce valid output - if (!string.IsNullOrEmpty(stdout)) - { - string portSuffix = $":{port}"; - var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - // Windows netstat format: Proto Local Address Foreign Address State PID - // Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345 - if (line.Contains("LISTENING") && line.Contains(portSuffix)) - { - var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); - // Verify the local address column actually ends with :{port} - // parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID - if (parts.Length >= 5) - { - string localAddr = parts[1]; - if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int pid)) - { - results.Add(pid); - } - } - } - } - } - } - else - { - // lsof: only return LISTENers (avoids capturing random clients) - // Use /usr/sbin/lsof directly as it might not be in PATH for Unity - string lsofPath = "/usr/sbin/lsof"; - if (!System.IO.File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback - - // -nP: avoid DNS/service name lookups; faster and less error-prone - success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr); - if (success && !string.IsNullOrWhiteSpace(stdout)) - { - var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var pidString in pidStrings) - { - if (int.TryParse(pidString.Trim(), out int pid)) - { - results.Add(pid); - } - } - } - } - } - catch (Exception ex) - { - McpLog.Warn($"Error checking port {port}: {ex.Message}"); - } - return results.Distinct().ToList(); + return _pidFileManager.TryGetPortFromPidFilePath(pidFilePath, out port); } - private static int GetCurrentProcessIdSafe() + private void DeletePidFile(string pidFilePath) { - try { return System.Diagnostics.Process.GetCurrentProcess().Id; } - catch { return -1; } + _pidFileManager.DeletePidFile(pidFilePath); } - private bool LooksLikeMcpServerProcess(int pid) + private List GetListeningProcessIdsForPort(int port) { - try - { - // Windows best-effort: First check process name with tasklist, then try to get command line with wmic - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Step 1: Check if process name matches known server executables - ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000); - string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant(); - - // Check for common process names - bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe"); - if (!isPythonOrUv) - { - return false; - } - - // Step 2: Try to get command line with wmic for better validation - ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000); - string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant(); - string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty); - - // If we can see the command line, validate it's our server - if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline=")) - { - bool mentionsMcp = wmicCompact.Contains("mcp-for-unity") - || wmicCompact.Contains("mcp_for_unity") - || wmicCompact.Contains("mcpforunity") - || wmicCompact.Contains("mcpforunityserver"); - bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http")); - bool mentionsUvicorn = wmicCombined.Contains("uvicorn"); - - if (mentionsMcp || mentionsTransport || mentionsUvicorn) - { - return true; - } - } - - // Fall back to just checking for python/uv processes if wmic didn't give us details - // This is less precise but necessary for cases where wmic access is restricted - return isPythonOrUv; - } - - // macOS/Linux: ps -p pid -ww -o comm= -o args= - // Use -ww to avoid truncating long command lines (important for reliably spotting 'mcp-for-unity'). - // Use an absolute ps path to avoid relying on PATH inside the Unity Editor process. - string psPath = "/bin/ps"; - if (!File.Exists(psPath)) psPath = "ps"; - // Important: ExecPath.TryRun returns false when exit code != 0, but ps output can still be useful. - // Always parse stdout/stderr regardless of exit code to avoid false negatives. - ExecPath.TryRun(psPath, $"-p {pid} -ww -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000); - string raw = ((psOut ?? string.Empty) + "\n" + (psErr ?? string.Empty)).Trim(); - string s = raw.ToLowerInvariant(); - string sCompact = NormalizeForMatch(raw); - if (!string.IsNullOrEmpty(s)) - { - bool mentionsMcp = sCompact.Contains("mcp-for-unity") - || sCompact.Contains("mcp_for_unity") - || sCompact.Contains("mcpforunity"); - - // If it explicitly mentions the server package/entrypoint, that is sufficient. - // Note: Check before Unity exclusion since "mcp-for-unity" contains "unity". - if (mentionsMcp) - { - return true; - } - - // Explicitly never kill Unity / Unity Hub processes - // Note: explicit !mentionsMcp is defensive; we already return early for mentionsMcp above. - if (s.Contains("unityhub") || s.Contains("unity hub") || (s.Contains("unity") && !mentionsMcp)) - { - return false; - } - - // Positive indicators - bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx "); - bool mentionsUv = s.Contains("uv ") || s.Contains("/uv"); - bool mentionsPython = s.Contains("python"); - bool mentionsUvicorn = s.Contains("uvicorn"); - bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http")); - - // Accept if it looks like uv/uvx/python launching our server package/entrypoint - if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport) - { - return true; - } - } - } - catch { } - - return false; + return _processDetector.GetListeningProcessIdsForPort(port); } - private static void LogStopDiagnosticsOnce(int pid, string details) + private int GetCurrentProcessIdSafe() { - try - { - if (LoggedStopDiagnosticsPids.Contains(pid)) - { - return; - } - LoggedStopDiagnosticsPids.Add(pid); - McpLog.Debug($"[StopLocalHttpServer] PID {pid} did not match server heuristics. {details}"); - } - catch { } + return _processDetector.GetCurrentProcessId(); } - private static string TrimForLog(string s) + private bool LooksLikeMcpServerProcess(int pid) { - if (string.IsNullOrEmpty(s)) return string.Empty; - const int max = 500; - if (s.Length <= max) return s; - return s.Substring(0, max) + "...(truncated)"; + return _processDetector.LooksLikeMcpServerProcess(pid); } private bool TerminateProcess(int pid) { - try - { - string stdout, stderr; - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // taskkill without /F first; fall back to /F if needed. - bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr); - if (!ok) - { - ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr); - } - return ok; - } - else - { - // Try a graceful termination first, then escalate if the process is still alive. - // Note: `kill -15` can succeed (exit 0) even if the process takes time to exit, - // so we verify and only escalate when needed. - string killPath = "/bin/kill"; - if (!File.Exists(killPath)) killPath = "kill"; - ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr); - - // Wait briefly for graceful shutdown. - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8); - while (DateTime.UtcNow < deadline) - { - if (!ProcessExistsUnix(pid)) - { - return true; - } - System.Threading.Thread.Sleep(100); - } - - // Escalate. - ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr); - return !ProcessExistsUnix(pid); - } - } - catch (Exception ex) - { - McpLog.Error($"Error killing process {pid}: {ex.Message}"); - return false; - } - } - - private static bool ProcessExistsUnix(int pid) - { - try - { - // ps exits non-zero when PID is not found. - string psPath = "/bin/ps"; - if (!File.Exists(psPath)) psPath = "ps"; - ExecPath.TryRun(psPath, $"-p {pid} -o pid=", Application.dataPath, out var stdout, out var stderr, 2000); - string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim(); - return !string.IsNullOrEmpty(combined) && combined.Any(char.IsDigit); - } - catch - { - return true; // Assume it exists if we cannot verify. - } + return _processTerminator.Terminate(pid); } /// @@ -1283,52 +828,7 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error) private bool TryGetLocalHttpServerCommandParts(out string fileName, out string arguments, out string displayCommand, out string error) { - fileName = null; - arguments = null; - displayCommand = null; - error = null; - - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - if (!useHttpTransport) - { - error = "HTTP transport is disabled. Enable it in the MCP For Unity window first."; - return false; - } - - string httpUrl = HttpEndpointUtility.GetBaseUrl(); - if (!IsLocalUrl()) - { - error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; - return false; - } - - var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - if (string.IsNullOrEmpty(uvxPath)) - { - error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings."; - return false; - } - - // 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; - bool projectScopedTools = EditorPrefs.GetBool( - EditorPrefKeys.ProjectScopedToolsLocalHttp, - true - ); - string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty; - - // Use centralized helper for beta server / prerelease args - string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); - - string args = string.IsNullOrEmpty(fromArgs) - ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}" - : $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}"; - - fileName = uvxPath; - arguments = args; - displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}"; - return true; + return _commandBuilder.TryBuildCommand(out fileName, out arguments, out displayCommand, out error); } /// @@ -1364,126 +864,13 @@ private static bool IsLocalUrl(string url) /// public bool CanStartLocalServer() { - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; return useHttpTransport && IsLocalUrl(); } - /// - /// Creates a ProcessStartInfo for opening a terminal window with the given command - /// Works cross-platform: macOS, Windows, and Linux - /// private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) { - if (string.IsNullOrWhiteSpace(command)) - throw new ArgumentException("Command cannot be empty", nameof(command)); - - command = command.Replace("\r", "").Replace("\n", ""); - -#if UNITY_EDITOR_OSX - // macOS: Avoid AppleScript (automation permission prompts). Use a .command script and open it. - string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); - Directory.CreateDirectory(scriptsDir); - string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.command"); - File.WriteAllText( - scriptPath, - "#!/bin/bash\n" + - "set -e\n" + - "clear\n" + - $"{command}\n"); - ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000); - return new System.Diagnostics.ProcessStartInfo - { - FileName = "/usr/bin/open", - Arguments = $"-a Terminal \"{scriptPath}\"", - UseShellExecute = false, - CreateNoWindow = true - }; -#elif UNITY_EDITOR_WIN - // Windows: Avoid brittle nested-quote escaping by writing a .cmd script and starting it in a new window. - string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); - Directory.CreateDirectory(scriptsDir); - string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.cmd"); - File.WriteAllText( - scriptPath, - "@echo off\r\n" + - "cls\r\n" + - command + "\r\n"); - return new System.Diagnostics.ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{scriptPath}\"", - UseShellExecute = false, - CreateNoWindow = true - }; -#else - // Linux: Try common terminal emulators - // We use bash -c to execute the command, so we must properly quote/escape for bash - // Escape single quotes for the inner bash string - string escapedCommandLinux = command.Replace("'", "'\\''"); - // Wrap the command in single quotes for bash -c - string script = $"'{escapedCommandLinux}; exec bash'"; - // Escape double quotes for the outer Process argument string - string escapedScriptForArg = script.Replace("\"", "\\\""); - string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\""; - - string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" }; - string terminalCmd = null; - - foreach (var term in terminals) - { - try - { - var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = "which", - Arguments = term, - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - }); - which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous - if (which.ExitCode == 0) - { - terminalCmd = term; - break; - } - } - catch { } - } - - if (terminalCmd == null) - { - terminalCmd = "xterm"; // Fallback - } - - // Different terminals have different argument formats - string args; - if (terminalCmd == "gnome-terminal") - { - args = $"-- {bashCmdArgs}"; - } - else if (terminalCmd == "konsole") - { - args = $"-e {bashCmdArgs}"; - } - else if (terminalCmd == "xfce4-terminal") - { - // xfce4-terminal expects -e "command string" or -e command arg - args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\""; - } - else // xterm and others - { - args = $"-hold -e {bashCmdArgs}"; - } - - return new System.Diagnostics.ProcessStartInfo - { - FileName = terminalCmd, - Arguments = args, - UseShellExecute = false, - CreateNoWindow = true - }; -#endif + return _terminalLauncher.CreateTerminalProcessStartInfo(command); } } } diff --git a/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs index dd2abaa45..a7c5f39d9 100644 --- a/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs +++ b/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs @@ -24,7 +24,7 @@ private static void OnBeforeAssemblyReload() try { // Only persist resume intent when stdio is the active transport and the bridge is running. - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; // Check both TransportManager AND StdioBridgeHost directly, because CI starts via StdioBridgeHost // bypassing TransportManager state. bool tmRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio); @@ -62,7 +62,7 @@ private static void OnAfterAssemblyReload() try { bool resumeFlag = EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false); - bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; resume = resumeFlag && !useHttp; // If we're not going to resume, clear the flag immediately to avoid stuck "Resuming..." state diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index 96b9243a4..bf2ffec4e 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -50,6 +50,7 @@ internal static class TestJobManager // Keep this small to avoid ballooning payloads during polling. private const int FailureCap = 25; private const long StuckThresholdMs = 60_000; + private const long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail private const int MaxJobsToKeep = 10; private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead @@ -84,6 +85,38 @@ public static bool HasRunningJob } } + /// + /// Force-clears any stuck or orphaned test job. Call this when tests get stuck due to + /// assembly reloads or other interruptions. + /// + /// True if a job was cleared, false if no running job exists. + public static bool ClearStuckJob() + { + bool cleared = false; + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId)) + { + return false; + } + + if (Jobs.TryGetValue(_currentJobId, out var job) && job.Status == TestJobStatus.Running) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + job.Status = TestJobStatus.Failed; + job.Error = "Job cleared manually (stuck or orphaned)"; + job.FinishedUnixMs = now; + job.LastUpdateUnixMs = now; + McpLog.Warn($"[TestJobManager] Manually cleared stuck job {_currentJobId}"); + cleared = true; + } + + _currentJobId = null; + } + PersistToSessionState(force: true); + return cleared; + } + private sealed class PersistedState { public string current_job_id { get; set; } @@ -442,10 +475,45 @@ internal static TestJob GetJob(string jobId) { return null; } + + TestJob jobToReturn = null; + bool shouldPersist = false; lock (LockObj) { - return Jobs.TryGetValue(jobId, out var job) ? job : null; + if (!Jobs.TryGetValue(jobId, out var job)) + { + return null; + } + + // Check if job is stuck in "running" state without having called OnRunStarted (TotalTests still null). + // This happens when tests fail to initialize (e.g., unsaved scene, compilation issues). + // After 15 seconds without initialization, auto-fail the job to prevent hanging. + if (job.Status == TestJobStatus.Running && job.TotalTests == null) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs) + { + McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing"); + job.Status = TestJobStatus.Failed; + job.Error = "Test job failed to initialize (tests did not start within timeout)"; + job.FinishedUnixMs = now; + job.LastUpdateUnixMs = now; + if (_currentJobId == jobId) + { + _currentJobId = null; + } + shouldPersist = true; + } + } + + jobToReturn = job; } + + if (shouldPersist) + { + PersistToSessionState(force: true); + } + return jobToReturn; } internal static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests) @@ -603,4 +671,3 @@ private static void FinalizeFromTask(string jobId, Task task) } } - diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs index 329511f72..10578436d 100644 --- a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -50,7 +50,7 @@ public List DiscoverAllTools() } } - McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection"); + McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection", false); return _cachedTools.Values.ToList(); } @@ -202,20 +202,7 @@ private string GetParameterType(Type type) return "object"; } - private string ConvertToSnakeCase(string input) - { - if (string.IsNullOrEmpty(input)) - return input; - - // Convert PascalCase to snake_case - var result = System.Text.RegularExpressions.Regex.Replace( - input, - "([a-z0-9])([A-Z])", - "$1_$2" - ).ToLower(); - - return result; - } + private string ConvertToSnakeCase(string input) => StringCaseUtility.ToSnakeCase(input); public void InvalidateCache() { diff --git a/MCPForUnity/Editor/Services/Transport/TransportManager.cs b/MCPForUnity/Editor/Services/Transport/TransportManager.cs index d221ab831..44f53ce0d 100644 --- a/MCPForUnity/Editor/Services/Transport/TransportManager.cs +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs @@ -24,9 +24,6 @@ public TransportManager() () => new StdioTransportClient()); } - public IMcpTransportClient ActiveTransport => null; // Deprecated single-transport accessor - public TransportMode? ActiveMode => null; // Deprecated single-transport accessor - public void Configure( Func webSocketFactory, Func stdioFactory) diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 68ab1b64c..ab3af5db7 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -10,6 +10,7 @@ using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Services.Transport; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.Prefabs; @@ -210,7 +211,7 @@ private static bool ShouldAutoStartBridge() { try { - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; return !useHttpTransport; } catch diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs index 65b4e4873..0b6c4aafc 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -49,6 +49,7 @@ public class WebSocketTransportClient : IMcpTransportClient, IDisposable private string _sessionId; private string _projectHash; private string _projectName; + private string _projectPath; private string _unityVersion; private TimeSpan _keepAliveInterval = DefaultKeepAliveInterval; private TimeSpan _socketKeepAliveInterval = DefaultKeepAliveInterval; @@ -80,6 +81,21 @@ public async Task StartAsync() _projectHash = ProjectIdentityUtility.GetProjectHash(); _unityVersion = Application.unityVersion; + // Get project root path (strip /Assets from dataPath) for focus nudging + string dataPath = Application.dataPath; + if (!string.IsNullOrEmpty(dataPath)) + { + string normalized = dataPath.TrimEnd('/', '\\'); + if (string.Equals(System.IO.Path.GetFileName(normalized), "Assets", StringComparison.Ordinal)) + { + _projectPath = System.IO.Path.GetDirectoryName(normalized) ?? normalized; + } + else + { + _projectPath = normalized; // Fallback if path doesn't end with Assets + } + } + await StopAsync(); _lifecycleCts = new CancellationTokenSource(); @@ -419,7 +435,7 @@ private async Task HandleRegisteredAsync(JObject payload, CancellationToken toke _sessionId = newSessionId; ProjectIdentityUtility.SetSessionId(_sessionId); _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); - McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}"); + McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}", false); await SendRegisterToolsAsync(token).ConfigureAwait(false); } @@ -432,7 +448,7 @@ private async Task SendRegisterToolsAsync(CancellationToken token) token.ThrowIfCancellationRequested(); var tools = await GetEnabledToolsOnMainThreadAsync(token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); - McpLog.Info($"[WebSocket] Preparing to register {tools.Count} tool(s) with the bridge."); + McpLog.Info($"[WebSocket] Preparing to register {tools.Count} tool(s) with the bridge.", false); var toolsArray = new JArray(); foreach (var tool in tools) @@ -472,7 +488,7 @@ private async Task SendRegisterToolsAsync(CancellationToken token) }; await SendJsonAsync(payload, token).ConfigureAwait(false); - McpLog.Info($"[WebSocket] Sent {tools.Count} tools registration"); + McpLog.Info($"[WebSocket] Sent {tools.Count} tools registration", false); } private async Task HandleExecuteAsync(JObject payload, CancellationToken token) @@ -576,7 +592,8 @@ private async Task SendRegisterAsync(CancellationToken token) // session_id is now server-authoritative; omitted here or sent as null ["project_name"] = _projectName, ["project_hash"] = _projectHash, - ["unity_version"] = _unityVersion + ["unity_version"] = _unityVersion, + ["project_path"] = _projectPath }; await SendJsonAsync(registerPayload, token).ConfigureAwait(false); @@ -662,7 +679,7 @@ private async Task AttemptReconnectAsync(CancellationToken token) { _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); _isConnected = true; - McpLog.Info("[WebSocket] Reconnected to MCP server"); + McpLog.Info("[WebSocket] Reconnected to MCP server", false); return; } } diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index c944acebb..d9df336d6 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; @@ -217,36 +216,6 @@ private static JToken NormalizeToken(JToken token) }; } - private static string ToCamelCase(string key) - { - if (string.IsNullOrEmpty(key) || key.IndexOf('_') < 0) - { - return key; - } - - var parts = key.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 0) - { - return key; - } - - var builder = new StringBuilder(parts[0]); - for (int i = 1; i < parts.Length; i++) - { - var part = parts[i]; - if (string.IsNullOrEmpty(part)) - { - continue; - } - - builder.Append(char.ToUpperInvariant(part[0])); - if (part.Length > 1) - { - builder.Append(part.AsSpan(1)); - } - } - - return builder.ToString(); - } + private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key); } } diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs index 25513113f..ca39ea51c 100644 --- a/MCPForUnity/Editor/Tools/CommandRegistry.cs +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.RegularExpressions; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Resources; @@ -51,18 +50,7 @@ public static void Initialize() _initialized = true; } - /// - /// Convert PascalCase or camelCase to snake_case - /// - private static string ToSnakeCase(string name) - { - if (string.IsNullOrEmpty(name)) return name; - - // Insert underscore before uppercase letters (except first) - var s1 = Regex.Replace(name, "(.)([A-Z][a-z]+)", "$1_$2"); - var s2 = Regex.Replace(s1, "([a-z0-9])([A-Z])", "$1_$2"); - return s2.ToLower(); - } + private static string ToSnakeCase(string name) => StringCaseUtility.ToSnakeCase(name); /// /// Auto-discover all types with [McpForUnityTool] or [McpForUnityResource] attributes @@ -98,7 +86,7 @@ private static void AutoDiscoverCommands() resourceCount++; } - McpLog.Info($"Auto-discovered {toolCount} tools and {resourceCount} resources ({_handlers.Count} total handlers)"); + McpLog.Info($"Auto-discovered {toolCount} tools and {resourceCount} resources ({_handlers.Count} total handlers)", false); } catch (Exception ex) { diff --git a/MCPForUnity/Editor/Tools/FindGameObjects.cs b/MCPForUnity/Editor/Tools/FindGameObjects.cs index f4b951549..d04f09429 100644 --- a/MCPForUnity/Editor/Tools/FindGameObjects.cs +++ b/MCPForUnity/Editor/Tools/FindGameObjects.cs @@ -28,9 +28,17 @@ public static object HandleCommand(JObject @params) return new ErrorResponse("Parameters cannot be null."); } + var p = new ToolParams(@params); + // Parse search parameters - string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], "by_name"); - string searchTerm = ParamCoercion.CoerceString(@params["searchTerm"] ?? @params["search_term"] ?? @params["target"], null); + string searchMethod = p.Get("searchMethod", "by_name"); + + // Try searchTerm, search_term, or target (for backwards compatibility) + string searchTerm = p.Get("searchTerm"); + if (string.IsNullOrEmpty(searchTerm)) + { + searchTerm = p.Get("target"); + } if (string.IsNullOrEmpty(searchTerm)) { @@ -41,8 +49,9 @@ public static object HandleCommand(JObject @params) var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50); pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500); - // Search options - bool includeInactive = ParamCoercion.CoerceBool(@params["includeInactive"] ?? @params["searchInactive"] ?? @params["include_inactive"], false); + // Search options (supports multiple parameter name variants) + bool includeInactive = p.GetBool("includeInactive", false) || + p.GetBool("searchInactive", false); try { diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs index b48a1b7b5..f5681858c 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs @@ -25,7 +25,10 @@ internal static object Handle(JToken targetToken, string searchMethod) { string goName = targetGo.name; int goId = targetGo.GetInstanceID(); - Undo.DestroyObjectImmediate(targetGo); + // Note: Undo.DestroyObjectImmediate doesn't work reliably in test context, + // so we use Object.DestroyImmediate. This means delete isn't undoable. + // TODO: Investigate Undo.DestroyObjectImmediate behavior in Unity 2022+ + Object.DestroyImmediate(targetGo); deletedObjects.Add(new { name = goName, instanceID = goId }); } } diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs index ba0c08855..44511e91e 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -47,7 +47,7 @@ internal static object Handle(JObject @params, JToken targetToken, string search // Rename the prefab asset file to match the new name (avoids Unity dialog) string assetPath = prefabStageForRename.assetPath; string directory = System.IO.Path.GetDirectoryName(assetPath); - string newAssetPath = System.IO.Path.Combine(directory, name + ".prefab").Replace('\\', '/'); + string newAssetPath = AssetPathUtility.NormalizeSeparators(System.IO.Path.Combine(directory, name + ".prefab")); // Only rename if the path actually changes if (newAssetPath != assetPath) diff --git a/MCPForUnity/Editor/Tools/GetTestJob.cs b/MCPForUnity/Editor/Tools/GetTestJob.cs index f618b7edd..30c4fbf85 100644 --- a/MCPForUnity/Editor/Tools/GetTestJob.cs +++ b/MCPForUnity/Editor/Tools/GetTestJob.cs @@ -19,25 +19,8 @@ public static object HandleCommand(JObject @params) return new ErrorResponse("Missing required parameter 'job_id'."); } - bool includeDetails = false; - bool includeFailedTests = false; - try - { - var includeDetailsToken = @params?["includeDetails"]; - if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails)) - { - includeDetails = parsedIncludeDetails; - } - var includeFailedTestsToken = @params?["includeFailedTests"]; - if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests)) - { - includeFailedTests = parsedIncludeFailedTests; - } - } - catch - { - // ignore parse failures - } + bool includeDetails = ParamCoercion.CoerceBool(@params?["includeDetails"], false); + bool includeFailedTests = ParamCoercion.CoerceBool(@params?["includeFailedTests"], false); var job = TestJobManager.GetJob(jobId); if (job == null) diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 8ae89fbab..27048031a 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -24,16 +24,27 @@ public static class ManageEditor /// public static object HandleCommand(JObject @params) { - string action = @params["action"]?.ToString()?.ToLowerInvariant(); - // Parameters for specific actions - string tagName = @params["tagName"]?.ToString(); - string layerName = @params["layerName"]?.ToString(); - bool waitForCompletion = @params["waitForCompletion"]?.ToObject() ?? false; // Example - not used everywhere + // Step 1: Null parameter guard (consistent across all tools) + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } - if (string.IsNullOrEmpty(action)) + // Step 2: Wrap parameters + var p = new ToolParams(@params); + + // Step 3: Extract and validate required parameters + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) { - return new ErrorResponse("Action parameter is required."); + return new ErrorResponse(actionResult.ErrorMessage); } + string action = actionResult.Value.ToLowerInvariant(); + + // Parameters for specific actions + string tagName = p.Get("tagName"); + string layerName = p.Get("layerName"); + bool waitForCompletion = p.GetBool("waitForCompletion", false); // Route action switch (action) @@ -86,29 +97,33 @@ public static object HandleCommand(JObject @params) // Tool Control case "set_active_tool": - string toolName = @params["toolName"]?.ToString(); - if (string.IsNullOrEmpty(toolName)) - return new ErrorResponse("'toolName' parameter required for set_active_tool."); - return SetActiveTool(toolName); + var toolNameResult = p.GetRequired("toolName", "'toolName' parameter required for set_active_tool."); + if (!toolNameResult.IsSuccess) + return new ErrorResponse(toolNameResult.ErrorMessage); + return SetActiveTool(toolNameResult.Value); // Tag Management case "add_tag": - if (string.IsNullOrEmpty(tagName)) - return new ErrorResponse("'tagName' parameter required for add_tag."); - return AddTag(tagName); + var addTagResult = p.GetRequired("tagName", "'tagName' parameter required for add_tag."); + if (!addTagResult.IsSuccess) + return new ErrorResponse(addTagResult.ErrorMessage); + return AddTag(addTagResult.Value); case "remove_tag": - if (string.IsNullOrEmpty(tagName)) - return new ErrorResponse("'tagName' parameter required for remove_tag."); - return RemoveTag(tagName); + var removeTagResult = p.GetRequired("tagName", "'tagName' parameter required for remove_tag."); + if (!removeTagResult.IsSuccess) + return new ErrorResponse(removeTagResult.ErrorMessage); + return RemoveTag(removeTagResult.Value); // Layer Management case "add_layer": - if (string.IsNullOrEmpty(layerName)) - return new ErrorResponse("'layerName' parameter required for add_layer."); - return AddLayer(layerName); + var addLayerResult = p.GetRequired("layerName", "'layerName' parameter required for add_layer."); + if (!addLayerResult.IsSuccess) + return new ErrorResponse(addLayerResult.ErrorMessage); + return AddLayer(addLayerResult.Value); case "remove_layer": - if (string.IsNullOrEmpty(layerName)) - return new ErrorResponse("'layerName' parameter required for remove_layer."); - return RemoveLayer(layerName); + var removeLayerResult = p.GetRequired("layerName", "'layerName' parameter required for remove_layer."); + if (!removeLayerResult.IsSuccess) + return new ErrorResponse(removeLayerResult.ErrorMessage); + return RemoveLayer(removeLayerResult.Value); // --- Settings (Example) --- // case "set_resolution": // int? width = @params["width"]?.ToObject(); diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 2f9e397b3..2c7230004 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -40,47 +40,23 @@ private sealed class SceneCommand private static SceneCommand ToSceneCommand(JObject p) { if (p == null) return new SceneCommand(); - int? BI(JToken t) - { - if (t == null || t.Type == JTokenType.Null) return null; - var s = t.ToString().Trim(); - if (s.Length == 0) return null; - if (int.TryParse(s, out var i)) return i; - if (double.TryParse(s, out var d)) return (int)d; - return t.Type == JTokenType.Integer ? t.Value() : (int?)null; - } - bool? BB(JToken t) - { - if (t == null || t.Type == JTokenType.Null) return null; - try - { - if (t.Type == JTokenType.Boolean) return t.Value(); - var s = t.ToString().Trim(); - if (s.Length == 0) return null; - if (bool.TryParse(s, out var b)) return b; - if (s == "1") return true; - if (s == "0") return false; - } - catch { } - return null; - } return new SceneCommand { action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), name = p["name"]?.ToString() ?? string.Empty, path = p["path"]?.ToString() ?? string.Empty, - buildIndex = BI(p["buildIndex"] ?? p["build_index"]), + buildIndex = ParamCoercion.CoerceIntNullable(p["buildIndex"] ?? p["build_index"]), fileName = (p["fileName"] ?? p["filename"])?.ToString() ?? string.Empty, - superSize = BI(p["superSize"] ?? p["super_size"] ?? p["supersize"]), + superSize = ParamCoercion.CoerceIntNullable(p["superSize"] ?? p["super_size"] ?? p["supersize"]), // get_hierarchy paging + safety parent = p["parent"], - pageSize = BI(p["pageSize"] ?? p["page_size"]), - cursor = BI(p["cursor"]), - maxNodes = BI(p["maxNodes"] ?? p["max_nodes"]), - maxDepth = BI(p["maxDepth"] ?? p["max_depth"]), - maxChildrenPerNode = BI(p["maxChildrenPerNode"] ?? p["max_children_per_node"]), - includeTransform = BB(p["includeTransform"] ?? p["include_transform"]), + pageSize = ParamCoercion.CoerceIntNullable(p["pageSize"] ?? p["page_size"]), + cursor = ParamCoercion.CoerceIntNullable(p["cursor"]), + maxNodes = ParamCoercion.CoerceIntNullable(p["maxNodes"] ?? p["max_nodes"]), + maxDepth = ParamCoercion.CoerceIntNullable(p["maxDepth"] ?? p["max_depth"]), + maxChildrenPerNode = ParamCoercion.CoerceIntNullable(p["maxChildrenPerNode"] ?? p["max_children_per_node"]), + includeTransform = ParamCoercion.CoerceBoolNullable(p["includeTransform"] ?? p["include_transform"]), }; } @@ -101,7 +77,7 @@ public static object HandleCommand(JObject @params) string relativeDir = path ?? string.Empty; if (!string.IsNullOrEmpty(relativeDir)) { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/'); if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); @@ -128,7 +104,7 @@ public static object HandleCommand(JObject @params) // Ensure relativePath always starts with "Assets/" and uses forward slashes string relativePath = string.IsNullOrEmpty(sceneFileName) ? null - : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); + : AssetPathUtility.NormalizeSeparators(Path.Combine("Assets", relativeDir, sceneFileName)); // Ensure directory exists for 'create' if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index 8f631979e..3e98172d5 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -60,10 +60,10 @@ public static class ManageScript /// private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) { - string assets = Application.dataPath.Replace('\\', '/'); + string assets = AssetPathUtility.NormalizeSeparators(Application.dataPath); // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." - string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); + string rel = AssetPathUtility.NormalizeSeparators(relDir ?? "Scripts").Trim(); if (string.IsNullOrEmpty(rel)) rel = "Scripts"; // Handle both "Assets" and "Assets/" prefixes @@ -78,8 +78,8 @@ private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, rel = rel.TrimStart('/'); - string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); - string full = Path.GetFullPath(targetDir).Replace('\\', '/'); + string targetDir = AssetPathUtility.NormalizeSeparators(Path.Combine(assets, rel)); + string full = AssetPathUtility.NormalizeSeparators(Path.GetFullPath(targetDir)); bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); @@ -129,19 +129,34 @@ public static object HandleCommand(JObject @params) return new ErrorResponse("invalid_params", "Parameters cannot be null."); } - // Extract parameters - string action = @params["action"]?.ToString()?.ToLowerInvariant(); - string name = @params["name"]?.ToString(); - string path = @params["path"]?.ToString(); // Relative to Assets/ + var p = new ToolParams(@params); + + // Extract and validate required parameters + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) + { + return new ErrorResponse(actionResult.ErrorMessage); + } + string action = actionResult.Value.ToLowerInvariant(); + + var nameResult = p.GetRequired("name"); + if (!nameResult.IsSuccess) + { + return new ErrorResponse(nameResult.ErrorMessage); + } + string name = nameResult.Value; + + // Optional parameters + string path = p.Get("path"); // Relative to Assets/ string contents = null; // Check if we have base64 encoded contents - bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; - if (contentsEncoded && @params["encodedContents"] != null) + bool contentsEncoded = p.GetBool("contentsEncoded", false); + if (contentsEncoded && p.Has("encodedContents")) { try { - contents = DecodeBase64(@params["encodedContents"].ToString()); + contents = DecodeBase64(p.Get("encodedContents")); } catch (Exception e) { @@ -150,21 +165,11 @@ public static object HandleCommand(JObject @params) } else { - contents = @params["contents"]?.ToString(); + contents = p.Get("contents"); } - string scriptType = @params["scriptType"]?.ToString(); // For templates/validation - string namespaceName = @params["namespace"]?.ToString(); // For organizing code - - // Validate required parameters - if (string.IsNullOrEmpty(action)) - { - return new ErrorResponse("Action parameter is required."); - } - if (string.IsNullOrEmpty(name)) - { - return new ErrorResponse("Name parameter is required."); - } + string scriptType = p.Get("scriptType"); // For templates/validation + string namespaceName = p.Get("namespace"); // For organizing code // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) { @@ -182,7 +187,7 @@ public static object HandleCommand(JObject @params) // Construct file paths string scriptFileName = $"{name}.cs"; string fullPath = Path.Combine(fullPathDir, scriptFileName); - string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); + string relativePath = AssetPathUtility.NormalizeSeparators(Path.Combine(relPathSafeDir, scriptFileName)); // Ensure the target directory exists for create/update if (action == "create" || action == "update") @@ -221,16 +226,17 @@ public static object HandleCommand(JObject @params) return DeleteScript(fullPath, relativePath); case "apply_text_edits": { - var textEdits = @params["edits"] as JArray; - string precondition = @params["precondition_sha256"]?.ToString(); - // Respect optional options - string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); - string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); + var textEdits = p.GetRaw("edits") as JArray; + string precondition = p.Get("precondition_sha256"); + // Respect optional options (guard type before indexing) + var optionsObj = p.GetRaw("options") as JObject; + string refreshOpt = optionsObj?["refresh"]?.ToString()?.ToLowerInvariant(); + string validateOpt = optionsObj?["validate"]?.ToString()?.ToLowerInvariant(); return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); } case "validate": { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + string level = p.Get("level", "standard").ToLowerInvariant(); var chosen = level switch { "basic" => ValidationLevel.Basic, @@ -2636,7 +2642,7 @@ static class ManageScriptRefreshHelpers public static string SanitizeAssetsPath(string p) { if (string.IsNullOrEmpty(p)) return p; - p = p.Replace('\\', '/').Trim(); + p = AssetPathUtility.NormalizeSeparators(p).Trim(); if (p.StartsWith("mcpforunity://path/", StringComparison.OrdinalIgnoreCase)) p = p.Substring("mcpforunity://path/".Length); while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) diff --git a/MCPForUnity/Editor/Tools/ManageScriptableObject.cs b/MCPForUnity/Editor/Tools/ManageScriptableObject.cs index 7c98317d5..62d1c19a5 100644 --- a/MCPForUnity/Editor/Tools/ManageScriptableObject.cs +++ b/MCPForUnity/Editor/Tools/ManageScriptableObject.cs @@ -1441,7 +1441,7 @@ private static string SanitizeSlashes(string path) return path; } - var s = path.Replace('\\', '/'); + var s = AssetPathUtility.NormalizeSeparators(path); while (s.IndexOf("//", StringComparison.Ordinal) >= 0) { s = s.Replace("//", "/", StringComparison.Ordinal); diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs index 67299d102..87b86feda 100644 --- a/MCPForUnity/Editor/Tools/ManageShader.cs +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -66,7 +66,7 @@ public static object HandleCommand(JObject @params) string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null if (!string.IsNullOrEmpty(relativeDir)) { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/'); if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); @@ -82,8 +82,9 @@ public static object HandleCommand(JObject @params) string shaderFileName = $"{name}.shader"; string fullPathDir = Path.Combine(Application.dataPath, relativeDir); string fullPath = Path.Combine(fullPathDir, shaderFileName); - string relativePath = Path.Combine("Assets", relativeDir, shaderFileName) - .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + string relativePath = AssetPathUtility.NormalizeSeparators( + Path.Combine("Assets", relativeDir, shaderFileName) + ); // Ensure "Assets/" prefix and forward slashes // Ensure the target directory exists for create/update if (action == "create" || action == "update") diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index 5b7fc0c7a..342f7b1fc 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -152,7 +152,13 @@ public static object HandleCommand(JObject @params) ); } - string action = @params["action"]?.ToString().ToLower() ?? "get"; + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + string action = p.Get("action", "get").ToLower(); try { @@ -164,18 +170,15 @@ public static object HandleCommand(JObject @params) { // Extract parameters for 'get' var types = - (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() + (p.GetRaw("types") as JArray)?.Select(t => t.ToString().ToLower()).ToList() ?? new List { "error", "warning" }; - int? count = @params["count"]?.ToObject(); - int? pageSize = - @params["pageSize"]?.ToObject() - ?? @params["page_size"]?.ToObject(); - int? cursor = @params["cursor"]?.ToObject(); - string filterText = @params["filterText"]?.ToString(); - string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering - string format = (@params["format"]?.ToString() ?? "plain").ToLower(); - bool includeStacktrace = - @params["includeStacktrace"]?.ToObject() ?? false; + int? count = p.GetInt("count"); + int? pageSize = p.GetInt("pageSize"); + int? cursor = p.GetInt("cursor"); + string filterText = p.Get("filterText"); + string sinceTimestampStr = p.Get("sinceTimestamp"); // TODO: Implement timestamp filtering + string format = p.Get("format", "plain").ToLower(); + bool includeStacktrace = p.GetBool("includeStacktrace", false); if (types.Contains("all")) { diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs index ad56be6ce..537472ac0 100644 --- a/MCPForUnity/Editor/Tools/RefreshUnity.cs +++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs @@ -23,20 +23,7 @@ public static async Task HandleCommand(JObject @params) string mode = @params?["mode"]?.ToString() ?? "if_dirty"; string scope = @params?["scope"]?.ToString() ?? "all"; string compile = @params?["compile"]?.ToString() ?? "none"; - bool waitForReady = false; - - try - { - var waitToken = @params?["wait_for_ready"]; - if (waitToken != null && bool.TryParse(waitToken.ToString(), out var parsed)) - { - waitForReady = parsed; - } - } - catch - { - // ignore parse failures - } + bool waitForReady = ParamCoercion.CoerceBool(@params?["wait_for_ready"], false); if (TestRunStatus.IsRunning) { diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index ccd0d085a..e9b55f2d6 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -20,6 +20,16 @@ public static Task HandleCommand(JObject @params) { try { + // Check for clear_stuck action first + if (ParamCoercion.CoerceBool(@params?["clear_stuck"], false)) + { + bool wasCleared = TestJobManager.ClearStuckJob(); + return Task.FromResult(new SuccessResponse( + wasCleared ? "Stuck job cleared." : "No running job to clear.", + new { cleared = wasCleared } + )); + } + string modeStr = @params?["mode"]?.ToString(); if (string.IsNullOrWhiteSpace(modeStr)) { @@ -31,26 +41,8 @@ public static Task HandleCommand(JObject @params) return Task.FromResult(new ErrorResponse(parseError)); } - bool includeDetails = false; - bool includeFailedTests = false; - try - { - var includeDetailsToken = @params?["includeDetails"]; - if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails)) - { - includeDetails = parsedIncludeDetails; - } - - var includeFailedTestsToken = @params?["includeFailedTests"]; - if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests)) - { - includeFailedTests = parsedIncludeFailedTests; - } - } - catch - { - // ignore parse failures - } + bool includeDetails = ParamCoercion.CoerceBool(@params?["includeDetails"], false); + bool includeFailedTests = ParamCoercion.CoerceBool(@params?["includeFailedTests"], false); var filterOptions = GetFilterOptions(@params); string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions); diff --git a/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs index fc5217c88..55e7b389f 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Reflection; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; @@ -248,24 +246,7 @@ private static JToken NormalizeToken(JToken token) return token; } - private static string ToCamelCase(string key) - { - if (string.IsNullOrEmpty(key) || key.IndexOf('_') < 0) - { - return key; - } - - var parts = key.Split('_'); - if (parts.Length == 0) - { - return key; - } - - var first = parts[0]; - var rest = string.Concat(parts.Skip(1).Select(part => - string.IsNullOrEmpty(part) ? "" : char.ToUpperInvariant(part[0]) + part.Substring(1))); - return first + rest; - } + private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key); public static object HandleCommand(JObject @params) { @@ -355,629 +336,37 @@ private static object HandleVFXGraphAction(JObject @params, string action) switch (action) { // Asset management - case "create_asset": return VFXCreateAsset(@params); - case "assign_asset": return VFXAssignAsset(@params); - case "list_templates": return VFXListTemplates(@params); - case "list_assets": return VFXListAssets(@params); - + case "create_asset": return VfxGraphAssets.CreateAsset(@params); + case "assign_asset": return VfxGraphAssets.AssignAsset(@params); + case "list_templates": return VfxGraphAssets.ListTemplates(@params); + case "list_assets": return VfxGraphAssets.ListAssets(@params); + // Runtime parameter control - case "get_info": return VFXGetInfo(@params); - case "set_float": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetFloat(n, v)); - case "set_int": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetInt(n, v)); - case "set_bool": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetBool(n, v)); - case "set_vector2": return VFXSetVector(@params, 2); - case "set_vector3": return VFXSetVector(@params, 3); - case "set_vector4": return VFXSetVector(@params, 4); - case "set_color": return VFXSetColor(@params); - case "set_gradient": return VFXSetGradient(@params); - case "set_texture": return VFXSetTexture(@params); - case "set_mesh": return VFXSetMesh(@params); - case "set_curve": return VFXSetCurve(@params); - case "send_event": return VFXSendEvent(@params); - case "play": return VFXControl(@params, "play"); - case "stop": return VFXControl(@params, "stop"); - case "pause": return VFXControl(@params, "pause"); - case "reinit": return VFXControl(@params, "reinit"); - case "set_playback_speed": return VFXSetPlaybackSpeed(@params); - case "set_seed": return VFXSetSeed(@params); + case "get_info": return VfxGraphRead.GetInfo(@params); + case "set_float": return VfxGraphWrite.SetParameter(@params, (vfx, n, v) => vfx.SetFloat(n, v)); + case "set_int": return VfxGraphWrite.SetParameter(@params, (vfx, n, v) => vfx.SetInt(n, v)); + case "set_bool": return VfxGraphWrite.SetParameter(@params, (vfx, n, v) => vfx.SetBool(n, v)); + case "set_vector2": return VfxGraphWrite.SetVector(@params, 2); + case "set_vector3": return VfxGraphWrite.SetVector(@params, 3); + case "set_vector4": return VfxGraphWrite.SetVector(@params, 4); + case "set_color": return VfxGraphWrite.SetColor(@params); + case "set_gradient": return VfxGraphWrite.SetGradient(@params); + case "set_texture": return VfxGraphWrite.SetTexture(@params); + case "set_mesh": return VfxGraphWrite.SetMesh(@params); + case "set_curve": return VfxGraphWrite.SetCurve(@params); + case "send_event": return VfxGraphWrite.SendEvent(@params); + case "play": return VfxGraphControl.Control(@params, "play"); + case "stop": return VfxGraphControl.Control(@params, "stop"); + case "pause": return VfxGraphControl.Control(@params, "pause"); + case "reinit": return VfxGraphControl.Control(@params, "reinit"); + case "set_playback_speed": return VfxGraphControl.SetPlaybackSpeed(@params); + case "set_seed": return VfxGraphControl.SetSeed(@params); default: return new { success = false, message = $"Unknown vfx action: {action}. Valid: create_asset, assign_asset, list_templates, list_assets, get_info, set_float, set_int, set_bool, set_vector2/3/4, set_color, set_gradient, set_texture, set_mesh, set_curve, send_event, play, stop, pause, reinit, set_playback_speed, set_seed" }; } #endif } -#if UNITY_VFX_GRAPH - private static VisualEffect FindVisualEffect(JObject @params) - { - GameObject go = ManageVfxCommon.FindTargetGameObject(@params); - return go?.GetComponent(); - } - - /// - /// Creates a new VFX Graph asset file from a template - /// - private static object VFXCreateAsset(JObject @params) - { - string assetName = @params["assetName"]?.ToString(); - string folderPath = @params["folderPath"]?.ToString() ?? "Assets/VFX"; - string template = @params["template"]?.ToString() ?? "empty"; - - if (string.IsNullOrEmpty(assetName)) - return new { success = false, message = "assetName is required" }; - - // Ensure folder exists - if (!AssetDatabase.IsValidFolder(folderPath)) - { - string[] folders = folderPath.Split('/'); - string currentPath = folders[0]; - for (int i = 1; i < folders.Length; i++) - { - string newPath = currentPath + "/" + folders[i]; - if (!AssetDatabase.IsValidFolder(newPath)) - { - AssetDatabase.CreateFolder(currentPath, folders[i]); - } - currentPath = newPath; - } - } - - string assetPath = $"{folderPath}/{assetName}.vfx"; - - // Check if asset already exists - if (AssetDatabase.LoadAssetAtPath(assetPath) != null) - { - bool overwrite = @params["overwrite"]?.ToObject() ?? false; - if (!overwrite) - return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." }; - AssetDatabase.DeleteAsset(assetPath); - } - - // Find and copy template - string templatePath = FindVFXTemplate(template); - UnityEngine.VFX.VisualEffectAsset newAsset = null; - - if (!string.IsNullOrEmpty(templatePath) && System.IO.File.Exists(templatePath)) - { - // templatePath is a full filesystem path, need to copy file directly - // Get the full destination path - string projectRoot = System.IO.Path.GetDirectoryName(Application.dataPath); - string fullDestPath = System.IO.Path.Combine(projectRoot, assetPath); - - // Ensure directory exists - string destDir = System.IO.Path.GetDirectoryName(fullDestPath); - if (!System.IO.Directory.Exists(destDir)) - System.IO.Directory.CreateDirectory(destDir); - - // Copy the file - System.IO.File.Copy(templatePath, fullDestPath, true); - AssetDatabase.Refresh(); - newAsset = AssetDatabase.LoadAssetAtPath(assetPath); - } - else - { - // Create empty VFX asset using reflection to access internal API - // Note: Develop in Progress, TODO:// Find authenticated way to create VFX asset - try - { - // Try to use VisualEffectAssetEditorUtility.CreateNewAsset if available - var utilityType = System.Type.GetType("UnityEditor.VFX.VisualEffectAssetEditorUtility, Unity.VisualEffectGraph.Editor"); - if (utilityType != null) - { - var createMethod = utilityType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static); - if (createMethod != null) - { - createMethod.Invoke(null, new object[] { assetPath }); - AssetDatabase.Refresh(); - newAsset = AssetDatabase.LoadAssetAtPath(assetPath); - } - } - - // Fallback: Create a ScriptableObject-based asset - if (newAsset == null) - { - // Try direct creation via internal constructor - var resourceType = System.Type.GetType("UnityEditor.VFX.VisualEffectResource, Unity.VisualEffectGraph.Editor"); - if (resourceType != null) - { - var createMethod = resourceType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); - if (createMethod != null) - { - var resource = createMethod.Invoke(null, new object[] { assetPath }); - AssetDatabase.Refresh(); - newAsset = AssetDatabase.LoadAssetAtPath(assetPath); - } - } - } - } - catch (Exception ex) - { - return new { success = false, message = $"Failed to create VFX asset: {ex.Message}" }; - } - } - - if (newAsset == null) - { - return new { success = false, message = "Failed to create VFX asset. Try using a template from list_templates." }; - } - - return new - { - success = true, - message = $"Created VFX asset: {assetPath}", - data = new - { - assetPath = assetPath, - assetName = newAsset.name, - template = template - } - }; - } - - /// - /// Finds VFX template path by name - /// - private static string FindVFXTemplate(string templateName) - { - // Get the actual filesystem path for the VFX Graph package using PackageManager API - var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); - - var searchPaths = new List(); - - if (packageInfo != null) - { - // Use the resolved path from PackageManager (handles Library/PackageCache paths) - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); - } - - // Also search project-local paths - searchPaths.Add("Assets/VFX/Templates"); - - string[] templatePatterns = new[] - { - $"{templateName}.vfx", - $"VFX{templateName}.vfx", - $"Simple{templateName}.vfx", - $"{templateName}VFX.vfx" - }; - - foreach (string basePath in searchPaths) - { - if (!System.IO.Directory.Exists(basePath)) continue; - - foreach (string pattern in templatePatterns) - { - string[] files = System.IO.Directory.GetFiles(basePath, pattern, System.IO.SearchOption.AllDirectories); - if (files.Length > 0) - return files[0]; - } - - // Also search by partial match - try - { - string[] allVfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); - foreach (string file in allVfxFiles) - { - if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower())) - return file; - } - } - catch { } - } - - // Search in project assets - string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName); - if (guids.Length > 0) - { - return AssetDatabase.GUIDToAssetPath(guids[0]); - } - - return null; - } - - /// - /// Assigns a VFX asset to a VisualEffect component - /// - private static object VFXAssignAsset(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect component not found" }; - - string assetPath = @params["assetPath"]?.ToString(); - if (string.IsNullOrEmpty(assetPath)) - return new { success = false, message = "assetPath is required" }; - - // Normalize path - if (!assetPath.StartsWith("Assets/") && !assetPath.StartsWith("Packages/")) - assetPath = "Assets/" + assetPath; - if (!assetPath.EndsWith(".vfx")) - assetPath += ".vfx"; - - var asset = AssetDatabase.LoadAssetAtPath(assetPath); - if (asset == null) - { - // Try searching by name - string searchName = System.IO.Path.GetFileNameWithoutExtension(assetPath); - string[] guids = AssetDatabase.FindAssets($"t:VisualEffectAsset {searchName}"); - if (guids.Length > 0) - { - assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); - asset = AssetDatabase.LoadAssetAtPath(assetPath); - } - } - - if (asset == null) - return new { success = false, message = $"VFX asset not found: {assetPath}" }; - - Undo.RecordObject(vfx, "Assign VFX Asset"); - vfx.visualEffectAsset = asset; - EditorUtility.SetDirty(vfx); - - return new - { - success = true, - message = $"Assigned VFX asset '{asset.name}' to {vfx.gameObject.name}", - data = new - { - gameObject = vfx.gameObject.name, - assetName = asset.name, - assetPath = assetPath - } - }; - } - - /// - /// Lists available VFX templates - /// - private static object VFXListTemplates(JObject @params) - { - var templates = new List(); - - // Get the actual filesystem path for the VFX Graph package using PackageManager API - var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); - - var searchPaths = new List(); - - if (packageInfo != null) - { - // Use the resolved path from PackageManager (handles Library/PackageCache paths) - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); - } - - // Also search project-local paths - searchPaths.Add("Assets/VFX/Templates"); - searchPaths.Add("Assets/VFX"); - - // Precompute normalized package path for comparison - string normalizedPackagePath = null; - if (packageInfo != null) - { - normalizedPackagePath = packageInfo.resolvedPath.Replace("\\", "/"); - } - - // Precompute the Assets base path for converting absolute paths to project-relative - string assetsBasePath = Application.dataPath.Replace("\\", "/"); - - foreach (string basePath in searchPaths) - { - if (!System.IO.Directory.Exists(basePath)) continue; - - try - { - string[] vfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); - foreach (string file in vfxFiles) - { - string absolutePath = file.Replace("\\", "/"); - string name = System.IO.Path.GetFileNameWithoutExtension(file); - bool isPackage = normalizedPackagePath != null && absolutePath.StartsWith(normalizedPackagePath); - - // Convert absolute path to project-relative path - string projectRelativePath; - if (isPackage) - { - // For package paths, convert to Packages/... format - projectRelativePath = "Packages/" + packageInfo.name + absolutePath.Substring(normalizedPackagePath.Length); - } - else if (absolutePath.StartsWith(assetsBasePath)) - { - // For project assets, convert to Assets/... format - projectRelativePath = "Assets" + absolutePath.Substring(assetsBasePath.Length); - } - else - { - // Fallback: use the absolute path if we can't determine the relative path - projectRelativePath = absolutePath; - } - - templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" }); - } - } - catch { } - } - - // Also search project assets - string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset"); - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - if (!templates.Any(t => ((dynamic)t).path == path)) - { - string name = System.IO.Path.GetFileNameWithoutExtension(path); - templates.Add(new { name = name, path = path, source = "project" }); - } - } - - return new - { - success = true, - data = new - { - count = templates.Count, - templates = templates - } - }; - } - - /// - /// Lists all VFX assets in the project - /// - private static object VFXListAssets(JObject @params) - { - string searchFolder = @params["folder"]?.ToString(); - string searchPattern = @params["search"]?.ToString(); - - string filter = "t:VisualEffectAsset"; - if (!string.IsNullOrEmpty(searchPattern)) - filter += " " + searchPattern; - - string[] guids; - if (!string.IsNullOrEmpty(searchFolder)) - guids = AssetDatabase.FindAssets(filter, new[] { searchFolder }); - else - guids = AssetDatabase.FindAssets(filter); - - var assets = new List(); - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - var asset = AssetDatabase.LoadAssetAtPath(path); - if (asset != null) - { - assets.Add(new - { - name = asset.name, - path = path, - guid = guid - }); - } - } - - return new - { - success = true, - data = new - { - count = assets.Count, - assets = assets - } - }; - } - - private static object VFXGetInfo(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - return new - { - success = true, - data = new - { - gameObject = vfx.gameObject.name, - assetName = vfx.visualEffectAsset?.name ?? "None", - aliveParticleCount = vfx.aliveParticleCount, - culled = vfx.culled, - pause = vfx.pause, - playRate = vfx.playRate, - startSeed = vfx.startSeed - } - }; - } - - private static object VFXSetParameter(JObject @params, Action setter) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - JToken valueToken = @params["value"]; - if (valueToken == null) return new { success = false, message = "Value required" }; - - Undo.RecordObject(vfx, $"Set VFX {param}"); - T value = valueToken.ToObject(); - setter(vfx, param, value); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set {param} = {value}" }; - } - - private static object VFXSetVector(JObject @params, int dims) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - Vector4 vec = ManageVfxCommon.ParseVector4(@params["value"]); - Undo.RecordObject(vfx, $"Set VFX {param}"); - - switch (dims) - { - case 2: vfx.SetVector2(param, new Vector2(vec.x, vec.y)); break; - case 3: vfx.SetVector3(param, new Vector3(vec.x, vec.y, vec.z)); break; - case 4: vfx.SetVector4(param, vec); break; - } - - EditorUtility.SetDirty(vfx); - return new { success = true, message = $"Set {param}" }; - } - - private static object VFXSetColor(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - Color color = ManageVfxCommon.ParseColor(@params["value"]); - Undo.RecordObject(vfx, $"Set VFX Color {param}"); - vfx.SetVector4(param, new Vector4(color.r, color.g, color.b, color.a)); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set color {param}" }; - } - - private static object VFXSetGradient(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - Gradient gradient = ManageVfxCommon.ParseGradient(@params["gradient"]); - Undo.RecordObject(vfx, $"Set VFX Gradient {param}"); - vfx.SetGradient(param, gradient); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set gradient {param}" }; - } - - private static object VFXSetTexture(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - string path = @params["texturePath"]?.ToString(); - if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and texturePath required" }; - - var findInst = new JObject { ["find"] = path }; - Texture tex = ObjectResolver.Resolve(findInst, typeof(Texture)) as Texture; - if (tex == null) return new { success = false, message = $"Texture not found: {path}" }; - - Undo.RecordObject(vfx, $"Set VFX Texture {param}"); - vfx.SetTexture(param, tex); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set texture {param} = {tex.name}" }; - } - - private static object VFXSetMesh(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - string path = @params["meshPath"]?.ToString(); - if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and meshPath required" }; - - var findInst = new JObject { ["find"] = path }; - Mesh mesh = ObjectResolver.Resolve(findInst, typeof(Mesh)) as Mesh; - if (mesh == null) return new { success = false, message = $"Mesh not found: {path}" }; - - Undo.RecordObject(vfx, $"Set VFX Mesh {param}"); - vfx.SetMesh(param, mesh); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set mesh {param} = {mesh.name}" }; - } - - private static object VFXSetCurve(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(@params["curve"], 1f); - Undo.RecordObject(vfx, $"Set VFX Curve {param}"); - vfx.SetAnimationCurve(param, curve); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set curve {param}" }; - } - - private static object VFXSendEvent(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string eventName = @params["eventName"]?.ToString(); - if (string.IsNullOrEmpty(eventName)) return new { success = false, message = "Event name required" }; - - VFXEventAttribute attr = vfx.CreateVFXEventAttribute(); - if (@params["position"] != null) attr.SetVector3("position", ManageVfxCommon.ParseVector3(@params["position"])); - if (@params["velocity"] != null) attr.SetVector3("velocity", ManageVfxCommon.ParseVector3(@params["velocity"])); - if (@params["color"] != null) { var c = ManageVfxCommon.ParseColor(@params["color"]); attr.SetVector3("color", new Vector3(c.r, c.g, c.b)); } - if (@params["size"] != null) attr.SetFloat("size", @params["size"].ToObject()); - if (@params["lifetime"] != null) attr.SetFloat("lifetime", @params["lifetime"].ToObject()); - - vfx.SendEvent(eventName, attr); - return new { success = true, message = $"Sent event '{eventName}'" }; - } - - private static object VFXControl(JObject @params, string action) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - switch (action) - { - case "play": vfx.Play(); break; - case "stop": vfx.Stop(); break; - case "pause": vfx.pause = !vfx.pause; break; - case "reinit": vfx.Reinit(); break; - } - - return new { success = true, message = $"VFX {action}", isPaused = vfx.pause }; - } - - private static object VFXSetPlaybackSpeed(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - float rate = @params["playRate"]?.ToObject() ?? 1f; - Undo.RecordObject(vfx, "Set VFX Play Rate"); - vfx.playRate = rate; - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set play rate = {rate}" }; - } - - private static object VFXSetSeed(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - uint seed = @params["seed"]?.ToObject() ?? 0; - bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject() ?? true; - - Undo.RecordObject(vfx, "Set VFX Seed"); - vfx.startSeed = seed; - vfx.resetSeedOnPlay = resetOnPlay; - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set seed = {seed}" }; - } -#endif #endregion diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs b/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs new file mode 100644 index 000000000..5f2e575f6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Asset management operations for VFX Graph. + /// Handles creating, assigning, and listing VFX assets. + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphAssets + { +#if !UNITY_VFX_GRAPH + public static object CreateAsset(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object AssignAsset(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object ListTemplates(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object ListAssets(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + private static readonly string[] SupportedVfxGraphVersions = { "12.1" }; + + /// + /// Creates a new VFX Graph asset file from a template. + /// + public static object CreateAsset(JObject @params) + { + string assetName = @params["assetName"]?.ToString(); + string folderPath = @params["folderPath"]?.ToString() ?? "Assets/VFX"; + string template = @params["template"]?.ToString() ?? "empty"; + + if (string.IsNullOrEmpty(assetName)) + { + return new { success = false, message = "assetName is required" }; + } + + string versionError = ValidateVfxGraphVersion(); + if (!string.IsNullOrEmpty(versionError)) + { + return new { success = false, message = versionError }; + } + + // Ensure folder exists + if (!AssetDatabase.IsValidFolder(folderPath)) + { + string[] folders = folderPath.Split('/'); + string currentPath = folders[0]; + for (int i = 1; i < folders.Length; i++) + { + string newPath = currentPath + "/" + folders[i]; + if (!AssetDatabase.IsValidFolder(newPath)) + { + AssetDatabase.CreateFolder(currentPath, folders[i]); + } + currentPath = newPath; + } + } + + string assetPath = $"{folderPath}/{assetName}.vfx"; + + // Check if asset already exists + if (AssetDatabase.LoadAssetAtPath(assetPath) != null) + { + bool overwrite = @params["overwrite"]?.ToObject() ?? false; + if (!overwrite) + { + return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." }; + } + AssetDatabase.DeleteAsset(assetPath); + } + + // Find template asset and copy it + string templatePath = FindTemplate(template); + string templateAssetPath = TryGetAssetPathFromFileSystem(templatePath); + VisualEffectAsset newAsset = null; + + if (!string.IsNullOrEmpty(templateAssetPath)) + { + // Copy the asset to create a new VFX Graph asset + if (!AssetDatabase.CopyAsset(templateAssetPath, assetPath)) + { + return new { success = false, message = $"Failed to copy VFX template from {templateAssetPath}" }; + } + AssetDatabase.Refresh(); + newAsset = AssetDatabase.LoadAssetAtPath(assetPath); + } + else + { + return new { success = false, message = "VFX template not found. Add a .vfx template asset or install VFX Graph templates." }; + } + + if (newAsset == null) + { + return new { success = false, message = "Failed to create VFX asset. Try using a template from list_templates." }; + } + + return new + { + success = true, + message = $"Created VFX asset: {assetPath}", + data = new + { + assetPath = assetPath, + assetName = newAsset.name, + template = template + } + }; + } + + /// + /// Finds VFX template path by name. + /// + private static string FindTemplate(string templateName) + { + // Get the actual filesystem path for the VFX Graph package using PackageManager API + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + + var searchPaths = new List(); + + if (packageInfo != null) + { + // Use the resolved path from PackageManager (handles Library/PackageCache paths) + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); + } + + // Also search project-local paths + searchPaths.Add("Assets/VFX/Templates"); + + string[] templatePatterns = new[] + { + $"{templateName}.vfx", + $"VFX{templateName}.vfx", + $"Simple{templateName}.vfx", + $"{templateName}VFX.vfx" + }; + + foreach (string basePath in searchPaths) + { + string searchRoot = basePath; + if (basePath.StartsWith("Assets/")) + { + searchRoot = System.IO.Path.Combine(UnityEngine.Application.dataPath, basePath.Substring("Assets/".Length)); + } + + if (!System.IO.Directory.Exists(searchRoot)) + { + continue; + } + + foreach (string pattern in templatePatterns) + { + string[] files = System.IO.Directory.GetFiles(searchRoot, pattern, System.IO.SearchOption.AllDirectories); + if (files.Length > 0) + { + return files[0]; + } + } + + // Also search by partial match + try + { + string[] allVfxFiles = System.IO.Directory.GetFiles(searchRoot, "*.vfx", System.IO.SearchOption.AllDirectories); + foreach (string file in allVfxFiles) + { + if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower())) + { + return file; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to search VFX templates under '{searchRoot}': {ex.Message}"); + } + } + + // Search in project assets + string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName); + if (guids.Length > 0) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); + // Convert asset path (e.g., "Assets/...") to absolute filesystem path + if (!string.IsNullOrEmpty(assetPath) && assetPath.StartsWith("Assets/")) + { + return System.IO.Path.Combine(UnityEngine.Application.dataPath, assetPath.Substring("Assets/".Length)); + } + if (!string.IsNullOrEmpty(assetPath) && assetPath.StartsWith("Packages/")) + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(assetPath); + if (info != null) + { + string relPath = assetPath.Substring(("Packages/" + info.name + "/").Length); + return System.IO.Path.Combine(info.resolvedPath, relPath); + } + } + return null; + } + + return null; + } + + /// + /// Assigns a VFX asset to a VisualEffect component. + /// + public static object AssignAsset(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect component not found" }; + } + + string assetPath = @params["assetPath"]?.ToString(); + if (string.IsNullOrEmpty(assetPath)) + { + return new { success = false, message = "assetPath is required" }; + } + + // Validate and normalize path + // Reject absolute paths, parent directory traversal, and backslashes + if (assetPath.Contains("\\") || assetPath.Contains("..") || System.IO.Path.IsPathRooted(assetPath)) + { + return new { success = false, message = "Invalid assetPath: traversal and absolute paths are not allowed" }; + } + + if (assetPath.StartsWith("Packages/")) + { + return new { success = false, message = "Invalid assetPath: VFX assets must live under Assets/." }; + } + + if (!assetPath.StartsWith("Assets/")) + { + assetPath = "Assets/" + assetPath; + } + if (!assetPath.EndsWith(".vfx")) + { + assetPath += ".vfx"; + } + + // Verify the normalized path doesn't escape the project + string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath, assetPath.Substring("Assets/".Length)); + string canonicalProjectRoot = System.IO.Path.GetFullPath(UnityEngine.Application.dataPath); + string canonicalAssetPath = System.IO.Path.GetFullPath(fullPath); + if (!canonicalAssetPath.StartsWith(canonicalProjectRoot + System.IO.Path.DirectorySeparatorChar) && + canonicalAssetPath != canonicalProjectRoot) + { + return new { success = false, message = "Invalid assetPath: would escape project directory" }; + } + + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + if (asset == null) + { + // Try searching by name + string searchName = System.IO.Path.GetFileNameWithoutExtension(assetPath); + string[] guids = AssetDatabase.FindAssets($"t:VisualEffectAsset {searchName}"); + if (guids.Length > 0) + { + assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); + asset = AssetDatabase.LoadAssetAtPath(assetPath); + } + } + + if (asset == null) + { + return new { success = false, message = $"VFX asset not found: {assetPath}" }; + } + + Undo.RecordObject(vfx, "Assign VFX Asset"); + vfx.visualEffectAsset = asset; + EditorUtility.SetDirty(vfx); + + return new + { + success = true, + message = $"Assigned VFX asset '{asset.name}' to {vfx.gameObject.name}", + data = new + { + gameObject = vfx.gameObject.name, + assetName = asset.name, + assetPath = assetPath + } + }; + } + + /// + /// Lists available VFX templates. + /// + public static object ListTemplates(JObject @params) + { + var templates = new List(); + var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Get the actual filesystem path for the VFX Graph package using PackageManager API + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + + var searchPaths = new List(); + + if (packageInfo != null) + { + // Use the resolved path from PackageManager (handles Library/PackageCache paths) + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); + } + + // Also search project-local paths + searchPaths.Add("Assets/VFX/Templates"); + searchPaths.Add("Assets/VFX"); + + // Precompute normalized package path for comparison + string normalizedPackagePath = null; + if (packageInfo != null) + { + normalizedPackagePath = packageInfo.resolvedPath.Replace("\\", "/"); + } + + // Precompute the Assets base path for converting absolute paths to project-relative + string assetsBasePath = Application.dataPath.Replace("\\", "/"); + + foreach (string basePath in searchPaths) + { + if (!System.IO.Directory.Exists(basePath)) + { + continue; + } + + try + { + string[] vfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); + foreach (string file in vfxFiles) + { + string absolutePath = file.Replace("\\", "/"); + string name = System.IO.Path.GetFileNameWithoutExtension(file); + bool isPackage = normalizedPackagePath != null && absolutePath.StartsWith(normalizedPackagePath); + + // Convert absolute path to project-relative path + string projectRelativePath; + if (isPackage) + { + // For package paths, convert to Packages/... format + projectRelativePath = "Packages/" + packageInfo.name + absolutePath.Substring(normalizedPackagePath.Length); + } + else if (absolutePath.StartsWith(assetsBasePath)) + { + // For project assets, convert to Assets/... format + projectRelativePath = "Assets" + absolutePath.Substring(assetsBasePath.Length); + } + else + { + // Fallback: use the absolute path if we can't determine the relative path + projectRelativePath = absolutePath; + } + + string normalizedPath = projectRelativePath.Replace("\\", "/"); + if (seenPaths.Add(normalizedPath)) + { + templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" }); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to list VFX templates under '{basePath}': {ex.Message}"); + } + } + + // Also search project assets + string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset"); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + string normalizedPath = path.Replace("\\", "/"); + if (seenPaths.Add(normalizedPath)) + { + string name = System.IO.Path.GetFileNameWithoutExtension(path); + templates.Add(new { name = name, path = path, source = "project" }); + } + } + + return new + { + success = true, + data = new + { + count = templates.Count, + templates = templates + } + }; + } + + /// + /// Lists all VFX assets in the project. + /// + public static object ListAssets(JObject @params) + { + string searchFolder = @params["folder"]?.ToString(); + string searchPattern = @params["search"]?.ToString(); + + string filter = "t:VisualEffectAsset"; + if (!string.IsNullOrEmpty(searchPattern)) + { + filter += " " + searchPattern; + } + + string[] guids; + if (!string.IsNullOrEmpty(searchFolder)) + { + if (searchFolder.Contains("\\") || searchFolder.Contains("..") || System.IO.Path.IsPathRooted(searchFolder)) + { + return new { success = false, message = "Invalid folder: traversal and absolute paths are not allowed" }; + } + + if (searchFolder.StartsWith("Packages/")) + { + return new { success = false, message = "Invalid folder: VFX assets must live under Assets/." }; + } + + if (!searchFolder.StartsWith("Assets/")) + { + searchFolder = "Assets/" + searchFolder; + } + + string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath, searchFolder.Substring("Assets/".Length)); + string canonicalProjectRoot = System.IO.Path.GetFullPath(UnityEngine.Application.dataPath); + string canonicalSearchFolder = System.IO.Path.GetFullPath(fullPath); + if (!canonicalSearchFolder.StartsWith(canonicalProjectRoot + System.IO.Path.DirectorySeparatorChar) && + canonicalSearchFolder != canonicalProjectRoot) + { + return new { success = false, message = "Invalid folder: would escape project directory" }; + } + + guids = AssetDatabase.FindAssets(filter, new[] { searchFolder }); + } + else + { + guids = AssetDatabase.FindAssets(filter); + } + + var assets = new List(); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + assets.Add(new + { + name = asset.name, + path = path, + guid = guid + }); + } + } + + return new + { + success = true, + data = new + { + count = assets.Count, + assets = assets + } + }; + } + + private static string ValidateVfxGraphVersion() + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + if (info == null) + { + return "VFX Graph package (com.unity.visualeffectgraph) not installed"; + } + + if (IsVersionSupported(info.version)) + { + return null; + } + + string supported = string.Join(", ", SupportedVfxGraphVersions.Select(version => $"{version}.x")); + return $"Unsupported VFX Graph version {info.version}. Supported versions: {supported}."; + } + + private static bool IsVersionSupported(string installedVersion) + { + if (string.IsNullOrEmpty(installedVersion)) + { + return false; + } + + string normalized = installedVersion; + int suffixIndex = normalized.IndexOfAny(new[] { '-', '+' }); + if (suffixIndex >= 0) + { + normalized = normalized.Substring(0, suffixIndex); + } + + if (!Version.TryParse(normalized, out Version installed)) + { + return false; + } + + foreach (string supported in SupportedVfxGraphVersions) + { + if (!Version.TryParse(supported, out Version target)) + { + continue; + } + + if (installed.Major == target.Major && installed.Minor == target.Minor) + { + return true; + } + } + + return false; + } + + private static string TryGetAssetPathFromFileSystem(string templatePath) + { + if (string.IsNullOrEmpty(templatePath)) + { + return null; + } + + string normalized = templatePath.Replace("\\", "/"); + string assetsRoot = Application.dataPath.Replace("\\", "/"); + + if (normalized.StartsWith(assetsRoot + "/")) + { + return "Assets/" + normalized.Substring(assetsRoot.Length + 1); + } + + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + if (packageInfo != null) + { + string packageRoot = packageInfo.resolvedPath.Replace("\\", "/"); + if (normalized.StartsWith(packageRoot + "/")) + { + return "Packages/" + packageInfo.name + "/" + normalized.Substring(packageRoot.Length + 1); + } + } + + return null; + } +#endif + } +} diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta b/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta new file mode 100644 index 000000000..17085b4b1 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1dfb51f038764a6da23619cac60f299 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs b/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs new file mode 100644 index 000000000..d342085ba --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Common utilities for VFX Graph operations. + /// + internal static class VfxGraphCommon + { +#if UNITY_VFX_GRAPH + /// + /// Finds a VisualEffect component on the target GameObject. + /// + public static VisualEffect FindVisualEffect(JObject @params) + { + if (@params == null) + return null; + + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } +#endif + } +} diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta b/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta new file mode 100644 index 000000000..b93197801 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a6dbf78125194cf29b98d658af1039a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs b/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs new file mode 100644 index 000000000..e90648157 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs @@ -0,0 +1,89 @@ +using Newtonsoft.Json.Linq; +using UnityEditor; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Playback control operations for VFX Graph (VisualEffect component). + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphControl + { +#if !UNITY_VFX_GRAPH + public static object Control(JObject @params, string action) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetPlaybackSpeed(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetSeed(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + public static object Control(JObject @params, string action) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + switch (action) + { + case "play": vfx.Play(); break; + case "stop": vfx.Stop(); break; + case "pause": vfx.pause = !vfx.pause; break; + case "reinit": vfx.Reinit(); break; + default: + return new { success = false, message = $"Unknown VFX action: {action}" }; + } + + return new { success = true, message = $"VFX {action}", isPaused = vfx.pause }; + } + + public static object SetPlaybackSpeed(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + float rate = @params["playRate"]?.ToObject() ?? 1f; + Undo.RecordObject(vfx, "Set VFX Play Rate"); + vfx.playRate = rate; + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set play rate = {rate}" }; + } + + public static object SetSeed(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + uint seed = @params["seed"]?.ToObject() ?? 0; + bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject() ?? true; + + Undo.RecordObject(vfx, "Set VFX Seed"); + vfx.startSeed = seed; + vfx.resetSeedOnPlay = resetOnPlay; + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set seed = {seed}" }; + } +#endif + } +} diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta b/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta new file mode 100644 index 000000000..7c112c166 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4720d53b13bc14989803670a788a1eaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs b/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs new file mode 100644 index 000000000..e3cb8f436 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Read operations for VFX Graph (VisualEffect component). + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphRead + { +#if !UNITY_VFX_GRAPH + public static object GetInfo(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + public static object GetInfo(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + return new + { + success = true, + data = new + { + gameObject = vfx.gameObject.name, + assetName = vfx.visualEffectAsset?.name ?? "None", + aliveParticleCount = vfx.aliveParticleCount, + culled = vfx.culled, + pause = vfx.pause, + playRate = vfx.playRate, + startSeed = vfx.startSeed + } + }; + } +#endif + } +} diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta b/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta new file mode 100644 index 000000000..ebf1e00da --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 419e293a95ea64af5ad6984b1d02b9b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs b/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs new file mode 100644 index 000000000..530bde9f2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs @@ -0,0 +1,310 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Parameter setter operations for VFX Graph (VisualEffect component). + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphWrite + { +#if !UNITY_VFX_GRAPH + public static object SetParameter(JObject @params, Action setter) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetVector(JObject @params, int dims) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetColor(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetGradient(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetTexture(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetMesh(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetCurve(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SendEvent(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + public static object SetParameter(JObject @params, Action setter) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + JToken valueToken = @params["value"]; + if (valueToken == null) + { + return new { success = false, message = "Value required" }; + } + + // Safely deserialize the value + T value; + try + { + value = valueToken.ToObject(); + } + catch (JsonException ex) + { + return new { success = false, message = $"Invalid value for {param}: {ex.Message}" }; + } + catch (InvalidCastException ex) + { + return new { success = false, message = $"Invalid value type for {param}: {ex.Message}" }; + } + + Undo.RecordObject(vfx, $"Set VFX {param}"); + setter(vfx, param, value); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set {param} = {value}" }; + } + + public static object SetVector(JObject @params, int dims) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + if (dims != 2 && dims != 3 && dims != 4) + { + return new { success = false, message = $"Unsupported vector dimension: {dims}. Expected 2, 3, or 4." }; + } + + Vector4 vec = ManageVfxCommon.ParseVector4(@params["value"]); + Undo.RecordObject(vfx, $"Set VFX {param}"); + + switch (dims) + { + case 2: vfx.SetVector2(param, new Vector2(vec.x, vec.y)); break; + case 3: vfx.SetVector3(param, new Vector3(vec.x, vec.y, vec.z)); break; + case 4: vfx.SetVector4(param, vec); break; + } + + EditorUtility.SetDirty(vfx); + return new { success = true, message = $"Set {param}" }; + } + + public static object SetColor(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + Color color = ManageVfxCommon.ParseColor(@params["value"]); + Undo.RecordObject(vfx, $"Set VFX Color {param}"); + vfx.SetVector4(param, new Vector4(color.r, color.g, color.b, color.a)); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set color {param}" }; + } + + public static object SetGradient(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + Gradient gradient = ManageVfxCommon.ParseGradient(@params["gradient"]); + Undo.RecordObject(vfx, $"Set VFX Gradient {param}"); + vfx.SetGradient(param, gradient); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set gradient {param}" }; + } + + public static object SetTexture(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + string path = @params["texturePath"]?.ToString(); + if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) + { + return new { success = false, message = "Parameter and texturePath required" }; + } + + var findInst = new JObject { ["find"] = path }; + Texture tex = ObjectResolver.Resolve(findInst, typeof(Texture)) as Texture; + if (tex == null) + { + return new { success = false, message = $"Texture not found: {path}" }; + } + + Undo.RecordObject(vfx, $"Set VFX Texture {param}"); + vfx.SetTexture(param, tex); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set texture {param} = {tex.name}" }; + } + + public static object SetMesh(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + string path = @params["meshPath"]?.ToString(); + if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) + { + return new { success = false, message = "Parameter and meshPath required" }; + } + + var findInst = new JObject { ["find"] = path }; + Mesh mesh = ObjectResolver.Resolve(findInst, typeof(Mesh)) as Mesh; + if (mesh == null) + { + return new { success = false, message = $"Mesh not found: {path}" }; + } + + Undo.RecordObject(vfx, $"Set VFX Mesh {param}"); + vfx.SetMesh(param, mesh); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set mesh {param} = {mesh.name}" }; + } + + public static object SetCurve(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(@params["curve"], 1f); + Undo.RecordObject(vfx, $"Set VFX Curve {param}"); + vfx.SetAnimationCurve(param, curve); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set curve {param}" }; + } + + public static object SendEvent(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string eventName = @params["eventName"]?.ToString(); + if (string.IsNullOrEmpty(eventName)) + { + return new { success = false, message = "Event name required" }; + } + + VFXEventAttribute attr = vfx.CreateVFXEventAttribute(); + if (@params["position"] != null) + { + attr.SetVector3("position", ManageVfxCommon.ParseVector3(@params["position"])); + } + if (@params["velocity"] != null) + { + attr.SetVector3("velocity", ManageVfxCommon.ParseVector3(@params["velocity"])); + } + if (@params["color"] != null) + { + var c = ManageVfxCommon.ParseColor(@params["color"]); + attr.SetVector3("color", new Vector3(c.r, c.g, c.b)); + } + if (@params["size"] != null) + { + float? sizeValue = @params["size"].Value(); + if (sizeValue.HasValue) + { + attr.SetFloat("size", sizeValue.Value); + } + } + if (@params["lifetime"] != null) + { + float? lifetimeValue = @params["lifetime"].Value(); + if (lifetimeValue.HasValue) + { + attr.SetFloat("lifetime", lifetimeValue.Value); + } + } + + vfx.SendEvent(eventName, attr); + return new { success = true, message = $"Sent event '{eventName}'" }; + } +#endif + } +} diff --git a/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta b/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta new file mode 100644 index 000000000..7ec78b68b --- /dev/null +++ b/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7516cdde6a4b648c9a2def6c26103cc4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 0277a6707..91fb26a65 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -270,7 +270,7 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client) // Capture ALL main-thread-only values before async task string projectDir = Path.GetDirectoryName(Application.dataPath); - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); @@ -453,7 +453,7 @@ private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImm // Capture main-thread-only values before async task string projectDir = Path.GetDirectoryName(Application.dataPath); - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); Task.Run(() => @@ -529,7 +529,7 @@ private void ApplyStatusToUi(IMcpClientConfigurator client, bool showChecking = bool hasTransportMismatch = false; if (client.ConfiguredTransport != ConfiguredTransport.Unknown) { - bool serverUsesHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool serverUsesHttp = EditorConfigurationCache.Instance.UseHttpTransport; ConfiguredTransport serverTransport = serverUsesHttp ? ConfiguredTransport.Http : ConfiguredTransport.Stdio; hasTransportMismatch = client.ConfiguredTransport != serverTransport; } diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 6a2586665..23e35f15c 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -39,7 +39,6 @@ private enum TransportProtocol private Label httpServerCommandHint; private TextField httpUrlField; private Button startHttpServerButton; - private Button stopHttpServerButton; private VisualElement unitySocketPortRow; private TextField unityPortField; private VisualElement statusIndicator; @@ -89,7 +88,6 @@ private void CacheUIElements() httpServerCommandHint = Root.Q