diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index 282578d554..1c63b4de19 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -27,6 +27,11 @@ Before applying a template: 5. Verify results → read_console, manage_camera(action="screenshot"), resources ``` +**If a tool you need is not in your tool list, it is not necessarily missing.** Check these before troubleshooting the connection: + +- **Project custom tools** (C# `[McpForUnityTool]`): with project-scoped tools enabled on the server, they are *not* callable by name. List them via `mcpforunity://custom-tools` and run them with `execute_custom_tool(tool_name="...", parameters={...})`. With it disabled, they are registered as ordinary tools and appear in your list. +- **Disabled tool groups**: over HTTP, a session starts with only the `core` group enabled. Over stdio, all groups start enabled and are then synced to the tool toggles in the Unity editor, so a tool can still be hidden. `manage_tools(action="list_groups")` shows the groups, `manage_tools(action="activate", group="...")` enables one. + ## Critical Best Practices ### 1. After Writing/Editing Scripts: Wait for Compilation and Check Console diff --git a/.gitattributes b/.gitattributes index 2698dc2fab..81ecf83b30 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,8 @@ # Shell scripts must keep LF: tools/compile-check.sh runs inside a Linux container, # where CRLF would fail with "bad interpreter". *.sh text eol=lf + +# compile-check.sh reads these line by line; a CR from a core.autocrlf checkout would +# become part of every -define: symbol and reference name. +tools/compile-defines.txt text eol=lf +tools/compile-refs/*.txt text eol=lf diff --git a/.github/workflows/e2e-bridge.yml b/.github/workflows/e2e-bridge.yml index 4cb9d7018e..75571a96cf 100644 --- a/.github/workflows/e2e-bridge.yml +++ b/.github/workflows/e2e-bridge.yml @@ -15,6 +15,8 @@ on: - "Server/src/**" - "Server/tests/e2e/**" - "tools/local_harness.py" + - "tools/ci_unity_license.py" + - "tools/tests/test_ci_unity_license.py" - ".github/workflows/e2e-bridge.yml" permissions: @@ -50,7 +52,9 @@ jobs: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -e - if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ] && [ -n "$UNITY_SERIAL" ]; }; then + # Only absent secrets skip a fork run. Partial configuration must fail + # explicitly in prepare, rather than silently look like missing access. + if [ -n "$UNITY_LICENSE$UNITY_EMAIL$UNITY_PASSWORD$UNITY_SERIAL" ]; then echo "unity_ok=true" >> "$GITHUB_OUTPUT" else echo "unity_ok=false" >> "$GITHUB_OUTPUT" @@ -90,87 +94,39 @@ jobs: echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" uv pip install -e Server - # --- License staging (mirrors claude-nl-suite.yml) --- - - name: Decide license sources - id: lic - shell: bash + # Activate with the same pinned GameCI implementation that runs our tests. + # Personal account credentials do not require a professional serial key. + - name: Prepare Unity license and activation helpers + id: prepare_license env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -eu - use_ulf=false; use_ebl=false - [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true - [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" && -n "${UNITY_SERIAL:-}" ]] && use_ebl=true - echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" - echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" - - - name: Stage Unity .ulf license (from secret) - if: steps.lic.outputs.use_ulf == 'true' - id: ulf - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - shell: bash - run: | - set -eu - mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" - f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" - if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then - printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" - else - printf "%s" "$UNITY_LICENSE" > "$f" - fi - chmod 600 "$f" || true - if grep -qi '' "$f"; then - cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" - echo "ok=true" >> "$GITHUB_OUTPUT" - else - echo "ok=false" >> "$GITHUB_OUTPUT" - fi + run: python3 tools/ci_unity_license.py prepare - - name: Activate Unity (EBL via container) - if: steps.lic.outputs.use_ebl == 'true' - shell: bash + - name: Activate Unity license env: - UNITY_IMAGE: ${{ env.UNITY_IMAGE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" - docker run --rm --network host \ - -e HOME=/root -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ - "$UNITY_IMAGE" bash -lc ' - set -euxo pipefail - /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true - ' + run: python3 tools/ci_unity_license.py activate - name: Warm up project (import Library once) shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} - ULF_OK: ${{ steps.ulf.outputs.ok }} run: | set -euxo pipefail - manual_args=() - if [[ "${ULF_OK:-false}" == "true" ]]; then - manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") - fi docker run --rm --network host \ -e HOME=/root \ + -v "$RUNNER_TEMP/unity-machine-id:/etc/machine-id:ro" \ -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ - "${manual_args[@]}" -quit + -quit - name: Clean old MCP status run: | @@ -182,32 +138,32 @@ jobs: shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} - ULF_OK: ${{ steps.ulf.outputs.ok }} run: | set -euxo pipefail # In --ci mode the harness drives the DockerLauncher: it runs the same # docker container (repo .unity-mcp status dir, docker liveness/teardown, # log redaction), waits on the status file, derives the instance, then # runs the smoke + EditMode + PlayMode legs over the bridge. - license_args=() - if [[ "${ULF_OK:-false}" == "true" ]]; then - license_args=(--editor-arg -manualLicenseFile \ - --editor-arg "/root/.local/share/unity3d/Unity/Unity_lic.ulf") - fi python3 tools/local_harness.py --ci \ --legs smoke,editmode,playmode \ --project-path TestProjects/UnityMCPTests \ - --reports reports \ - "${license_args[@]}" + --reports reports - - name: Unity logs on failure - if: failure() - run: docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true + - name: Return Unity license seat + if: always() && steps.prepare_license.outcome == 'success' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: python3 tools/ci_unity_license.py return - name: Upload E2E report if: always() uses: actions/upload-artifact@v4 with: name: e2e-bridge-report - path: reports/junit-*.xml + # The harness snapshots redacted Editor logs before removing its container. + path: | + reports/junit-*.xml + reports/unity-editor-*.log if-no-files-found: ignore diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index d21112b5c7..0f4315ba4f 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -47,8 +47,11 @@ jobs: - name: Install dependencies run: | cd Server - uv sync - uv pip install -e ".[dev]" + # --locked fails if uv.lock disagrees with pyproject.toml instead of silently + # re-resolving, so a dependency or version bump that forgot `uv lock` shows up here. + # The dev extra is resolved in the lock too, so pytest runs against pinned versions + # instead of whatever `uv pip install` would fetch today. + uv sync --locked --extra dev - name: Run tests with coverage run: | diff --git a/CLAUDE.md b/CLAUDE.md index 5ebb8a1827..815a255ece 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ from services.registry import mcp_for_unity_tool @mcp_for_unity_tool( description="Does something in Unity.", - group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder, profiling, docs + group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder, profiling, docs, asset_gen ) async def manage_something( ctx: Context, @@ -165,6 +165,12 @@ cd Server && uv run pytest tests/ -k "test_create_material" -v # Local multi-version compile check (parity with CI matrix, see tools/unity-versions.json) tools/check-unity-versions.sh # compile-only across installed Unity Hub editors tools/check-unity-versions.sh --full # full EditMode test run + +# License-free Roslyn compile of MCPForUnity, the same gate compile-check.yml runs on every PR. +# No Editor launch, ~1 min per version. EXTRA_REFS must hold Newtonsoft.Json.dll and +# nunit.framework.dll (copy them from TestProjects/UnityMCPTests/Library/PackageCache); +# the script header has the full recipe, including the Windows/Git Bash form. +UNITY_DATA=/path/to/Editor/Data UNITY_VERSION=2021.3.45f2 EXTRA_REFS=/path/to/refs tools/compile-check.sh ``` #### Local headless test harness diff --git a/MCPForUnity/Editor/Clients/Configurators/PiConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/PiConfigurator.cs new file mode 100644 index 0000000000..ade5a703b5 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/PiConfigurator.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Pi (pi.dev) configurator. + /// + /// Two things make Pi different from every other JSON client in this folder, and both are + /// load-bearing rather than cosmetic: + /// + /// 1. Pi has no built-in MCP client. As of pi 0.84.x there is no mcp subcommand, + /// no --mcp-config equivalent, and no mcp.json reader anywhere in the + /// distributed package; its own README states "No MCP. Build CLI tools with READMEs, or + /// build an extension that adds MCP support." Reaching this server from Pi therefore + /// requires an MCP extension, and the unityMCP entry written below is what that + /// extension reads. names the extension explicitly, + /// because on Pi a config file alone is a silent no-op — the exact "configured, enabled, and + /// silently providing nothing" shape this window exists to prevent. + /// + /// 2. The file is shared, so presence cannot be inferred from it. Pi MCP extensions read + /// the tool-agnostic ~/.config/mcp/mcp.json, not a Pi-owned path. That makes the + /// inherited IsInstalled ("does the config path's parent directory exist") wrong in + /// both directions — see . + /// + public class PiConfigurator : JsonFileMcpConfigurator + { + public PiConfigurator() : base(new McpClient + { + name = "Pi", + windowsConfigPath = GetSharedMcpConfigPath(), + macConfigPath = GetSharedMcpConfigPath(), + linuxConfigPath = GetSharedMcpConfigPath() + }) + { } + + /// + /// Pi-exclusive presence check, deliberately NOT the inherited + /// ParentDirectoryExists(GetConfigPath()). + /// + /// + /// The config path here is a SHARED, tool-agnostic file. Its parent directory therefore says + /// nothing about whether Pi exists: a Cursor or Claude Code user has a + /// ~/.config/mcp tree without ever installing Pi, and a Pi user who has not yet + /// configured any MCP server has no ~/.config/mcp at all. Keying detection on that + /// path would make "Configure All Detected Clients" offer Pi on machines that do not have + /// it while skipping the exact machines this configurator exists to fix. + /// + /// + /// + /// Pi's own agent directory is the correct marker: pi creates it on first run for settings, + /// sessions and auth, nothing else writes there, and PI_CODING_AGENT_DIR relocates + /// it — so an explicitly relocated install is detected too rather than hardcoding + /// ~/.pi. + /// + /// + public override bool IsInstalled + { + get + { + string agentDir = GetPiAgentDirectory(); + return !string.IsNullOrEmpty(agentDir) && Directory.Exists(agentDir); + } + } + + public override IList GetInstallationSteps() => new List + { + "Install an MCP extension for Pi — Pi ships no MCP client of its own:\npi install npm:pi-mcp-adapter", + "Restart Pi so the extension loads (extensions are read at startup)", + "Paste the configuration JSON into the file at the path above, or use Configure", + "Start Pi and ask it to search MCP — e.g. \"search MCP for manage_gameobject\"" + }; + + /// The tool-agnostic shared MCP config Pi MCP extensions read, in the user's home. + private static string GetSharedMcpConfigPath() + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".config", "mcp", "mcp.json"); + } + + /// + /// Pi's agent directory: $PI_CODING_AGENT_DIR when set (Pi's own documented + /// relocation override), else ~/.pi/agent. Null when the home directory cannot be + /// resolved, which callers must treat as "not installed" rather than as a match. + /// + private static string GetPiAgentDirectory() + { + string overrideDir = Environment.GetEnvironmentVariable("PI_CODING_AGENT_DIR"); + if (!string.IsNullOrWhiteSpace(overrideDir)) + return overrideDir.Trim(); + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrEmpty(home) ? null : Path.Combine(home, ".pi", "agent"); + } + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/PiConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/PiConfigurator.cs.meta new file mode 100644 index 0000000000..2e4b06e638 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/PiConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 068ab66d5e4b41f98382460c25b4f3a8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/TraeCnConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/TraeCnConfigurator.cs new file mode 100644 index 0000000000..eaece4e20c --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/TraeCnConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class TraeCnConfigurator : JsonFileMcpConfigurator + { + public TraeCnConfigurator() : base(new McpClient + { + name = "Trae CN", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Trae CN", "User", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Trae CN", "User", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Trae CN", "User", "mcp.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Trae CN and go to Settings > MCP", + "Select Add Server > Add Manually", + "Paste the JSON or point to the mcp.json file\n"+ + "Windows: %AppData%\\Trae CN\\User\\mcp.json\n" + + "macOS: ~/Library/Application Support/Trae CN/User/mcp.json\n" + + "Linux: ~/.config/Trae CN/User/mcp.json\n", + "Save and restart Trae CN" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/TraeCnConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/TraeCnConfigurator.cs.meta new file mode 100644 index 0000000000..57fa896a36 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/TraeCnConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: adece9c580b24ae7add84b1a533820c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 74fd836f7c..c4f021b2cc 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -1370,51 +1370,126 @@ private static (JObject serverConfig, string error) ReadUserScopeConfig(string p if (projects == null) return (null, null); - // Build a dictionary of normalized paths for quick lookup - // Use last entry for duplicates (forward/backslash variants) as it's typically more recent + // Build a dictionary of normalized paths for quick lookup. + // Duplicate keys (forward/backslash variants of the same path) + // are merged by preferring the entry that actually carries a + // UnityMCP registration: JSON property order is not correlated + // with recency across variants, so blind last-entry-wins lets a + // stale variant without mcpServers shadow a real registration + // and CheckStatus reports NotConfigured despite a working + // `claude mcp add --scope local` setup. var normalizedProjects = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var project in projects.Properties()) { string normalizedPath = NormalizePath(project.Name); - normalizedProjects[normalizedPath] = project.Value as JObject; + var value = project.Value as JObject; + if (!normalizedProjects.TryGetValue(normalizedPath, out var existing) + || RegistrationRank(value) >= RegistrationRank(existing)) + { + normalizedProjects[normalizedPath] = value; + } } // Walk up the directory tree to find a matching project config // Claude Code may be configured at a parent directory (e.g., repo root) // while Unity project is in a subdirectory (e.g., TestProjects/UnityMCPTests) - string currentDir = NormalizePath(projectDir); - while (!string.IsNullOrEmpty(currentDir)) + var fromProjectDir = FindUnityServerFromWalk(normalizedProjects, NormalizePath(projectDir)); + if (fromProjectDir != null) + return (fromProjectDir, null); + + // `claude mcp add --scope local` keys the registration by the + // git MAIN repo root. When the Unity project lives in a linked + // worktree, that root is a sibling path the ancestor walk above + // can never reach — retry from the parsed main root. + string mainRoot = GetGitMainRepoRoot(projectDir); + if (!string.IsNullOrEmpty(mainRoot) + && !string.Equals(mainRoot, NormalizePath(projectDir), StringComparison.OrdinalIgnoreCase)) { - if (normalizedProjects.TryGetValue(currentDir, out var projectConfig)) + var fromMainRoot = FindUnityServerFromWalk(normalizedProjects, mainRoot); + if (fromMainRoot != null) + return (fromMainRoot, null); + } + + return (null, null); + } + catch (Exception ex) + { + return (null, $"Error reading user Claude config: {ex.Message}"); + } + } + + /// + /// Walks up from startDir (normalized) looking for a project entry with + /// a UnityMCP registration. Stops at the first project entry found even + /// if it lacks UnityMCP (a configured project boundary). + /// + private static JObject FindUnityServerFromWalk(Dictionary normalizedProjects, string startDir) + { + string currentDir = startDir; + while (!string.IsNullOrEmpty(currentDir)) + { + if (normalizedProjects.TryGetValue(currentDir, out var projectConfig)) + { + var mcpServers = projectConfig?["mcpServers"] as JObject; + if (mcpServers != null) { - var mcpServers = projectConfig?["mcpServers"] as JObject; - if (mcpServers != null) + foreach (var server in mcpServers.Properties()) { - foreach (var server in mcpServers.Properties()) + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) - { - return (server.Value as JObject, null); - } + return server.Value as JObject; } } - // Found the project but no UnityMCP - don't continue walking up - return (null, null); } - - // Move up one directory - int lastSlash = currentDir.LastIndexOf('/'); - if (lastSlash <= 0) - break; - currentDir = currentDir.Substring(0, lastSlash); + // Found the project but no UnityMCP - don't continue walking up + return null; } - return (null, null); + // Move up one directory + int lastSlash = currentDir.LastIndexOf('/'); + if (lastSlash <= 0) + break; + currentDir = currentDir.Substring(0, lastSlash); } - catch (Exception ex) + return null; + } + + /// + /// For a linked git worktree, the main repo root parsed from the .git + /// pointer file ("gitdir: <root>/.git/worktrees/<name>"), + /// normalized. Null for a regular checkout (.git directory) or + /// anything unparseable. + /// + private static string GetGitMainRepoRoot(string dir) + { + try { - return (null, $"Error reading user Claude config: {ex.Message}"); + string gitPath = Path.Combine(dir, ".git"); + if (!File.Exists(gitPath)) return null; + string line = File.ReadAllText(gitPath).Trim(); + if (!line.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) return null; + string gitDir = line.Substring("gitdir:".Length).Trim(); + if (!Path.IsPathRooted(gitDir)) gitDir = Path.Combine(dir, gitDir); + gitDir = NormalizePath(Path.GetFullPath(gitDir)); + int i = gitDir.LastIndexOf("/.git/", StringComparison.OrdinalIgnoreCase); + return i > 0 ? gitDir.Substring(0, i) : null; + } + catch { return null; } + } + + /// + /// Ranks a ~/.claude.json project entry for duplicate-key merging: + /// 2 = has a UnityMCP registration, 1 = has other mcpServers, 0 = none. + /// + private static int RegistrationRank(JObject projectConfig) + { + if (!(projectConfig?["mcpServers"] is JObject servers)) return 0; + foreach (var server in servers.Properties()) + { + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) + return 2; } + return servers.HasValues ? 1 : 0; } /// diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 6f7ca9f04d..01e72b18f8 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -86,5 +86,12 @@ internal static class EditorPrefKeys internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot"; internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize"; internal const string AssetGenProviderEnabledPrefix = "MCPForUnity.AssetGen.Enabled."; + + // Blender Bridge (Asset Gen tab). Machine-specific, non-secret. An empty ForkPath means the + // addon sync / update-check features are not configured; import and screenshot still work. + internal const string BlenderHost = "MCPForUnity.Blender.Host"; + internal const string BlenderPort = "MCPForUnity.Blender.Port"; + internal const string BlenderForkPath = "MCPForUnity.Blender.ForkPath"; + internal const string BlenderAddonsDir = "MCPForUnity.Blender.AddonsDir"; } } diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs index c3802c475f..79c4156e0f 100644 --- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -60,6 +60,9 @@ public static DependencyCheckResult CheckAllDependencies() var uvStatus = detector.DetectUv(); result.Dependencies.Add(uvStatus); + // Check git (optional: Package Manager Git-URL installs only) + result.Dependencies.Add(detector.DetectGit()); + // Generate summary and recommendations result.GenerateSummary(); GenerateRecommendations(result, detector); diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs index 3231105e9d..08a3c221e8 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -27,6 +27,11 @@ public interface IPlatformDetector /// DependencyStatus DetectUv(); + /// + /// Detect git on this platform. Optional: only the Package Manager's Git-URL install path needs it. + /// + DependencyStatus DetectGit(); + /// /// Get platform-specific installation recommendations /// diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs index be9db17f6e..4dd344eb79 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -190,6 +190,7 @@ private string[] GetPathAdditions() var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); return new[] { + Path.Combine(homeDir, ".pyenv", "shims"), // pyenv: Python/uv when Unity is launched from a desktop entry "/usr/local/bin", "/usr/bin", "/bin", diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs index c955381d19..c2ae4f9480 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -64,6 +64,67 @@ public virtual DependencyStatus DetectUv() } + // Git is not needed to run the bridge, only to add or update the package from a Git URL + // in the Package Manager, which is the install path most users take (issue #1216). It is + // reported as optional so a missing git never blocks setup, but the row tells the user why + // "Error when executing git command" appeared and how to clear it. + public const string GitInstallUrl = "https://git-scm.com/downloads"; + + public virtual DependencyStatus DetectGit() + { + var status = new DependencyStatus("Git", isRequired: false) + { + InstallationHint = GitInstallUrl + }; + + try + { + if (!TryFindInPath("git", out string gitPath)) + { + status.ErrorMessage = "git not found"; + status.Details = "Only needed to add or update MCP for Unity from a Git URL in the Package Manager."; + return status; + } + + if (ExecPath.TryRun(gitPath, "--version", null, out string stdout, out string stderr, 5000) + && TryParseGitVersion(string.IsNullOrWhiteSpace(stdout) ? stderr : stdout, out string version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = gitPath; + status.Details = "If the Package Manager still reports 'not in a git directory', git is refusing a folder " + + "owned by another user: run git config --global --add safe.directory \"\""; + return status; + } + + status.ErrorMessage = "git found but did not report a version"; + status.Path = gitPath; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting git: {ex.Message}"; + } + + return status; + } + + /// Parses "git version 2.45.1.windows.1" or "git version 2.39.5 (Apple Git-154)" into "2.45.1.windows.1" / "2.39.5". + internal static bool TryParseGitVersion(string output, out string version) + { + version = null; + string line = (output ?? string.Empty).Trim(); + const string prefix = "git version "; + if (!line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string rest = line.Substring(prefix.Length).Trim(); + int end = rest.IndexOfAny(new[] { ' ', '\r', '\n' }); + version = end >= 0 ? rest.Substring(0, end) : rest; + return version.Length > 0 && char.IsDigit(version[0]); + } + protected bool TryParseVersion(string version, out int major, out int minor) { major = 0; diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs index 706e5030f8..000858bd91 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -29,22 +29,14 @@ public override DependencyStatus DetectPython() try { - // Try running python directly first (works with Windows App Execution Aliases) - if (TryValidatePython("python3.exe", out string version, out string fullPath) || - TryValidatePython("python.exe", out version, out fullPath)) + // Probe every python-like entry on PATH instead of only the first match. A single + // name routinely resolves to several files on Windows - the Microsoft Store App + // Execution Alias stub, pyenv-win's .bat shims, and real interpreters - and stopping + // at the first one made pyenv (and any setup shadowed by the Store stub) report + // "Python not found" even though the interpreter works fine from a terminal. + foreach (string candidate in EnumeratePythonCandidates()) { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH"; - return status; - } - - // Fallback: try 'where' command - if (TryFindInPath("python3.exe", out string pathResult) || - TryFindInPath("python.exe", out pathResult)) - { - if (TryValidatePython(pathResult, out version, out fullPath)) + if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; @@ -55,12 +47,12 @@ public override DependencyStatus DetectPython() } // Fallback: try to find python via uv - if (TryFindPythonViaUv(out version, out fullPath)) + if (TryFindPythonViaUv(out string uvVersion, out string uvFullPath)) { status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} via uv"; + status.Version = uvVersion; + status.Path = uvFullPath; + status.Details = $"Found Python {uvVersion} via uv"; return status; } @@ -152,6 +144,54 @@ public override DependencyStatus DetectUv() } + /// + /// Every python-like executable reachable from PATH, in priority order and de-duplicated. + /// The extension-less names matter: they let 'where' expand through PATHEXT, which is what + /// surfaces pyenv-win's python.bat shims next to regular python.exe installs. + /// + private IEnumerable EnumeratePythonCandidates() + { + string augmentedPath = BuildAugmentedPath(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string name in new[] { "python3.exe", "python.exe", "python3", "python" }) + { + foreach (string match in ExecPath.FindAllInPath(name, augmentedPath)) + { + if (seen.Add(match)) yield return match; + } + } + + // Last resort: hand the bare names to the process launcher so a working App Execution + // Alias that 'where' failed to report still gets a chance to answer --version. + foreach (string name in new[] { "python3.exe", "python.exe" }) + { + if (seen.Add(name)) yield return name; + } + } + + /// + /// Whether a path found in 'uv python list' output looks like a launchable interpreter. + /// pyenv-win registers its interpreters as .bat shims, so restricting this to .exe hid + /// perfectly valid installs. + /// + internal static bool IsPythonExecutable(string path) + { + string extension = Path.GetExtension(path); + if (!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".bat", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string name = Path.GetFileNameWithoutExtension(path); + + // pythonw is the console-less variant and never writes a version to stdout/stderr + return name.StartsWith("python", StringComparison.OrdinalIgnoreCase) && + !name.StartsWith("pythonw", StringComparison.OrdinalIgnoreCase); + } + private bool TryFindPythonViaUv(out string version, out string fullPath) { version = null; @@ -173,8 +213,7 @@ private bool TryFindPythonViaUv(out string version, out string fullPath) if (parts.Length >= 2) { string potentialPath = parts[parts.Length - 1]; - if (File.Exists(potentialPath) && - (potentialPath.EndsWith("python.exe") || potentialPath.EndsWith("python3.exe"))) + if (File.Exists(potentialPath) && IsPythonExecutable(potentialPath)) { if (TryValidatePython(potentialPath, out version, out fullPath)) { @@ -201,9 +240,10 @@ private bool TryValidatePython(string pythonPath, out string version, out string { string augmentedPath = BuildAugmentedPath(); - // First, try to resolve the absolute path for better UI/logging display + // First, try to resolve the absolute path for better UI/logging display. + // Already-rooted candidates are passed through: 'where' rejects full paths. string commandToRun = pythonPath; - if (TryFindInPath(pythonPath, out string resolvedPath)) + if (!Path.IsPathRooted(pythonPath) && TryFindInPath(pythonPath, out string resolvedPath)) { commandToRun = resolvedPath; } @@ -287,6 +327,17 @@ private string[] GetPathAdditions() catch { /* Ignore if directory doesn't exist */ } } + // pyenv-win: shims are what 'python' resolves to in a terminal, but Unity launched from + // the Hub does not always inherit the PATH entry that pyenv's installer added. + var pyenvRoot = Environment.GetEnvironmentVariable("PYENV"); + if (string.IsNullOrEmpty(pyenvRoot) && !string.IsNullOrEmpty(homeDir)) + pyenvRoot = Path.Combine(homeDir, ".pyenv", "pyenv-win"); + if (!string.IsNullOrEmpty(pyenvRoot)) + { + additions.Add(Path.Combine(pyenvRoot, "shims")); + additions.Add(Path.Combine(pyenvRoot, "bin")); + } + // User scripts if (!string.IsNullOrEmpty(homeDir)) additions.Add(Path.Combine(homeDir, ".local", "bin")); diff --git a/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs new file mode 100644 index 0000000000..c3af3662fa --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs @@ -0,0 +1,93 @@ +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Blender; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user, NON-SECRET configuration for the Blender Bridge (Asset Gen tab): where the + /// BlenderMCP addon socket listens, where the user's blender-mcp checkout lives, and where + /// Blender keeps its user addons. Nothing here is required for the bridge to talk to Blender; + /// the checkout path only unlocks addon sync and update checks. EditorPrefs is main-thread + /// only, so callers capture what they need (e.g. ) before going async. + /// + public static class BlenderBridgePrefs + { + public const string DefaultHost = "127.0.0.1"; + public const int DefaultPort = 9876; + public const string AddonFileName = "addon.py"; + + public static string Host + { + get => EditorPrefs.GetString(EditorPrefKeys.BlenderHost, DefaultHost); + set => SetOrDelete(EditorPrefKeys.BlenderHost, value); + } + + public static int Port + { + get => EditorPrefs.GetInt(EditorPrefKeys.BlenderPort, DefaultPort); + set => EditorPrefs.SetInt(EditorPrefKeys.BlenderPort, value > 0 && value <= 65535 ? value : DefaultPort); + } + + /// Host and port as one value, safe to hand to a background thread. + public static BlenderEndpoint Endpoint => new BlenderEndpoint(Host, Port); + + /// Local checkout of the blender-mcp repository (contains addon.py). Empty = not configured. + public static string ForkPath + { + get => NormalizePath(EditorPrefs.GetString(EditorPrefKeys.BlenderForkPath, string.Empty)); + set => SetOrDelete(EditorPrefKeys.BlenderForkPath, NormalizePath(value)); + } + + /// + /// Blender's user addons directory. Empty = auto-detect the newest + /// <user config>/Blender/<version>/scripts/addons that already has the addon installed. + /// + public static string AddonsDirOverride + { + get => NormalizePath(EditorPrefs.GetString(EditorPrefKeys.BlenderAddonsDir, string.Empty)); + set => SetOrDelete(EditorPrefKeys.BlenderAddonsDir, NormalizePath(value)); + } + + public static bool IsForkConfigured => !string.IsNullOrEmpty(ForkPath); + + public static string ForkAddonPath => IsForkConfigured ? ForkPath + "/" + AddonFileName : null; + + /// The override when set, otherwise the newest detected user addons folder; null if none. + public static string ResolveAddonsDir() + { + string o = AddonsDirOverride; + return !string.IsNullOrEmpty(o) ? o : BlenderDetection.FindUserAddonsDir(AddonFileName); + } + + public static string InstalledAddonPath + { + get + { + string dir = ResolveAddonsDir(); + return string.IsNullOrEmpty(dir) ? null : dir + "/" + AddonFileName; + } + } + + /// True when the checkout path points at a folder that actually contains addon.py. + public static bool IsValidForkPath(string path) + { + string p = NormalizePath(path); + return !string.IsNullOrEmpty(p) && File.Exists(Path.Combine(p, AddonFileName)); + } + + /// Trims, converts backslashes to slashes and drops a trailing slash. + internal static string NormalizePath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/').TrimEnd('/'); + } + + /// Stores a string pref, deleting the key when the value is blank so defaults apply. + private static void SetOrDelete(string key, string value) + { + if (string.IsNullOrWhiteSpace(value)) EditorPrefs.DeleteKey(key); + else EditorPrefs.SetString(key, value.Trim()); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta new file mode 100644 index 0000000000..5dafa8364f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 029b420c97f34c738e98b875fe56bf73 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs b/MCPForUnity/Editor/Helpers/BlenderDetection.cs index 6af74e344e..06c0aad952 100644 --- a/MCPForUnity/Editor/Helpers/BlenderDetection.cs +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs @@ -1,22 +1,65 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// - /// Best-effort detection of a locally installed Blender application, for the Asset Gen tab's - /// "Blender → Unity handoff" hint. This finds the Blender APP only — it cannot tell whether the - /// BlenderMCP server is configured in the user's AI client (that lives outside Unity). + /// Best-effort detection of a locally installed Blender application and of Blender's per-user + /// addons folders, for the Asset Gen tab's Blender Bridge. This finds the Blender APP and its + /// config folders only — whether the BlenderMCP addon is running is a socket question answered + /// by the bridge itself. /// internal static class BlenderDetection { - /// True if a Blender executable is found in a well-known location or on PATH. + /// + /// Package-family name prefix of the Microsoft Store (MSIX) Blender app; the publisher suffix + /// after it varies. Kept without the trailing '_' so separately packaged LTS releases match too. + /// + internal const string StorePackagePrefix = "BlenderFoundation.Blender"; + + /// + /// True if a Blender executable is found in a well-known location or on PATH, or a Microsoft + /// Store Blender package is installed for this user (its install folder is not readable). + /// public static bool IsInstalled() { - try { return DetectIn(CandidatePaths(), File.Exists); } + try + { + return DetectIn(CandidatePaths(), File.Exists) + || HasStorePackage(StorePackagesRoot(), root => Directory.EnumerateDirectories(root, StorePackagePrefix + "*")); + } + catch { return false; } + } + + /// + /// Pure core of the Store check: true if reports a folder under + /// whose name starts with . A missing + /// root or a listing failure is a "no". Testable. + /// + internal static bool HasStorePackage(string packagesRoot, Func> listDirectories) + { + if (string.IsNullOrEmpty(packagesRoot) || listDirectories == null) return false; + List dirs; + try { dirs = listDirectories(packagesRoot)?.ToList(); } catch { return false; } + foreach (string d in dirs ?? new List()) + { + if (string.IsNullOrEmpty(d)) continue; + if (Path.GetFileName(d.TrimEnd('/', '\\')).StartsWith(StorePackagePrefix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + /// %LOCALAPPDATA%/Packages on Windows, where each Store app keeps a per-user data folder; null elsewhere. + internal static string StorePackagesRoot() + { + if (Application.platform != RuntimePlatform.WindowsEditor) return null; + string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return string.IsNullOrEmpty(local) ? null : Path.Combine(local, "Packages"); } /// Pure core: true if reports any candidate present. Testable. @@ -28,12 +71,16 @@ internal static bool DetectIn(IEnumerable candidates, Func return false; } - /// Well-known Blender executable paths for the current platform, plus PATH entries. + /// + /// Well-known Blender executable paths for the current platform (installer, Steam's default + /// library, package managers), plus PATH entries. + /// internal static IEnumerable CandidatePaths() { var list = new List(); bool win = Application.platform == RuntimePlatform.WindowsEditor; string exeName = win ? "blender.exe" : "blender"; + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); // PATH entries: /blender(.exe) string pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; @@ -44,9 +91,11 @@ internal static IEnumerable CandidatePaths() { case RuntimePlatform.OSXEditor: list.Add("/Applications/Blender.app/Contents/MacOS/Blender"); - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (!string.IsNullOrEmpty(home)) + { list.Add(Path.Combine(home, "Applications/Blender.app/Contents/MacOS/Blender")); + list.Add(Path.Combine(home, "Library/Application Support/Steam/steamapps/common/Blender/Blender.app/Contents/MacOS/Blender")); + } break; case RuntimePlatform.WindowsEditor: foreach (string pf in new[] @@ -56,6 +105,7 @@ internal static IEnumerable CandidatePaths() }) { if (string.IsNullOrEmpty(pf)) continue; + list.Add(Path.Combine(pf, "Steam", "steamapps", "common", "Blender", "blender.exe")); string foundation = Path.Combine(pf, "Blender Foundation"); // Blender installs under a version subdir (Blender X.Y); enumerate them. try @@ -72,9 +122,94 @@ internal static IEnumerable CandidatePaths() list.Add("/usr/local/bin/blender"); list.Add("/snap/bin/blender"); list.Add("/var/lib/flatpak/exports/bin/org.blender.Blender"); + if (!string.IsNullOrEmpty(home)) + { + list.Add(Path.Combine(home, ".local/share/flatpak/exports/bin/org.blender.Blender")); + list.Add(Path.Combine(home, ".steam/steam/steamapps/common/Blender/blender")); + list.Add(Path.Combine(home, ".local/share/Steam/steamapps/common/Blender/blender")); + } break; } return list; } + + /// + /// Blender's per-user config roots (the folder that holds one subfolder per Blender version): + /// %APPDATA%/Blender Foundation/Blender on Windows, ~/Library/Application Support/Blender on + /// macOS, $XDG_CONFIG_HOME/blender or ~/.config/blender on Linux. + /// + internal static IEnumerable UserConfigRoots() + { + var list = new List(); + switch (Application.platform) + { + case RuntimePlatform.WindowsEditor: + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrEmpty(appData)) + list.Add(Path.Combine(appData, "Blender Foundation", "Blender")); + break; + case RuntimePlatform.OSXEditor: + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + list.Add(Path.Combine(home, "Library", "Application Support", "Blender")); + break; + case RuntimePlatform.LinuxEditor: + string xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + string linuxHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(xdg)) list.Add(Path.Combine(xdg, "blender")); + else if (!string.IsNullOrEmpty(linuxHome)) list.Add(Path.Combine(linuxHome, ".config", "blender")); + break; + } + return list; + } + + /// <root>/<X.Y>/scripts/addons for every versioned config folder, newest version first. + internal static IEnumerable UserAddonsDirs() + { + var found = new List<(Version Ver, string Dir)>(); + foreach (string root in UserConfigRoots()) + { + try + { + if (!Directory.Exists(root)) continue; + foreach (string d in Directory.GetDirectories(root)) + { + Version v = ParseVersion(Path.GetFileName(d)); + if (v != null) found.Add((v, Path.Combine(d, "scripts", "addons"))); + } + } + catch { /* unreadable dir; ignore */ } + } + return found.OrderByDescending(x => x.Ver).Select(x => x.Dir.Replace('\\', '/')).ToList(); + } + + /// Newest user addons dir that already contains , else the newest one, else null. + internal static string FindUserAddonsDir(string fileName) + { + try { return PickAddonsDir(UserAddonsDirs(), File.Exists, fileName); } + catch { return null; } + } + + /// Pure core of . Testable. + internal static string PickAddonsDir(IEnumerable dirsNewestFirst, Func fileExists, string fileName) + { + if (dirsNewestFirst == null) return null; + string first = null; + foreach (string d in dirsNewestFirst) + { + if (string.IsNullOrEmpty(d)) continue; + first ??= d; + if (!string.IsNullOrEmpty(fileName) && fileExists != null && fileExists(d.TrimEnd('/') + "/" + fileName)) + return d; + } + return first; + } + + /// Parses a Blender version folder name ("4.2", "5.2") into a comparable Version; null if it is not one. + internal static Version ParseVersion(string name) + { + if (string.IsNullOrEmpty(name)) return null; + return Version.TryParse(name.Contains('.') ? name : name + ".0", out Version v) ? v : null; + } } } diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 412542139e..f69f85f26c 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -166,6 +166,27 @@ internal static void ClearClaudeCliPath() catch { } } + /// + /// Assigns PATH on a ProcessStartInfo using the spelling the inherited environment already + /// uses. Mono - the runtime the Editor runs on - backs EnvironmentVariables with a + /// case-SENSITIVE dictionary, so writing "PATH" when Windows handed us "Path" adds a second, + /// separate entry and the child process keeps reading the original one. That silently made + /// every extraPathPrepend on Windows a no-op. + /// + private static void SetPathVariable(ProcessStartInfo psi, string value) + { + string key = "PATH"; + foreach (string existing in psi.EnvironmentVariables.Keys) + { + if (string.Equals(existing, "PATH", StringComparison.OrdinalIgnoreCase)) + { + key = existing; + break; + } + } + psi.EnvironmentVariables[key] = value; + } + internal static bool TryRun( string file, string args, @@ -179,16 +200,41 @@ internal static bool TryRun( stderr = string.Empty; try { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + // Handle PowerShell scripts on Windows by invoking through powershell.exe - bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && - file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + bool isPs1 = isWindows && file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + + // Handle batch shims (pyenv-win, npm .cmd wrappers, ...) on Windows: CreateProcess + // cannot launch .bat/.cmd directly while UseShellExecute is false, so route them + // through cmd.exe instead of failing with a Win32Exception. + bool isBatch = isWindows && + (file.EndsWith(".bat", StringComparison.OrdinalIgnoreCase) || + file.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)); + + string fileName; + string arguments; + if (isPs1) + { + fileName = "powershell.exe"; + arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim(); + } + else if (isBatch) + { + fileName = "cmd.exe"; + // /s /c plus an outer pair of quotes keeps paths containing spaces intact + arguments = $"/s /c \"\"{file}\" {args}\""; + } + else + { + fileName = file; + arguments = args; + } var psi = new ProcessStartInfo { - FileName = isPs1 ? "powershell.exe" : file, - Arguments = isPs1 - ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() - : args, + FileName = fileName, + Arguments = arguments, WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, UseShellExecute = false, RedirectStandardOutput = true, @@ -198,9 +244,9 @@ internal static bool TryRun( if (!string.IsNullOrEmpty(extraPathPrepend)) { string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) + SetPathVariable(psi, string.IsNullOrEmpty(currentPath) ? extraPathPrepend - : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); + : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath)); } using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; @@ -249,6 +295,22 @@ internal static string FindInPath(string executable, string extraPathPrepend = n #endif } + /// + /// Like , but returns every match in PATH order instead of only the + /// first one. On Windows a single name can resolve to several entries (App Execution Alias + /// stubs, pyenv-win .bat shims, real interpreters); callers that validate the candidate need + /// to be able to skip the ones that turn out to be non-functional. + /// + internal static string[] FindAllInPath(string executable, string extraPathPrepend = null) + { +#if UNITY_EDITOR_WIN + return FindAllInPathWindows(executable, extraPathPrepend); +#else + string single = FindInPath(executable, extraPathPrepend); + return string.IsNullOrEmpty(single) ? Array.Empty() : new[] { single }; +#endif + } + #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX private static string Which(string exe, string prependPath) { @@ -261,7 +323,7 @@ private static string Which(string exe, string prependPath) CreateNoWindow = true, }; string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + SetPathVariable(psi, string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path)); using var p = Process.Start(psi); if (p == null) return null; @@ -286,6 +348,11 @@ private static string Which(string exe, string prependPath) #if UNITY_EDITOR_WIN internal static string FindInPathWindows(string exe, string extraPathPrepend = null) + { + return FindAllInPathWindows(exe, extraPathPrepend).FirstOrDefault(); + } + + private static string[] FindAllInPathWindows(string exe, string extraPathPrepend = null) { try { @@ -303,11 +370,11 @@ internal static string FindInPathWindows(string exe, string extraPathPrepend = n }; if (!string.IsNullOrEmpty(effectivePath)) { - psi.EnvironmentVariables["PATH"] = effectivePath; + SetPathVariable(psi, effectivePath); } using var p = Process.Start(psi); - if (p == null) return null; + if (p == null) return Array.Empty(); var so = new StringBuilder(); p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; @@ -316,16 +383,17 @@ internal static string FindInPathWindows(string exe, string extraPathPrepend = n if (!p.WaitForExit(1500)) { try { p.Kill(); } catch { } - return null; + return Array.Empty(); } p.WaitForExit(); - string first = so.ToString() + return so.ToString() .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(); - return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && File.Exists(line)) + .ToArray(); } - catch { return null; } + catch { return Array.Empty(); } } #endif } diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs index f97113618c..edd20c12fa 100644 --- a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -714,6 +714,7 @@ private static object ConvertJTokenToPlainObject(JToken token) new RectConverter(), new BoundsConverter(), new Matrix4x4Converter(), // Fix #478: Safe Matrix4x4 serialization for Cinemachine + new UnityMathematicsConverter(), // Fix #1415: float3/quaternion swizzle properties made the default walk never finish new UnityEngineObjectConverter() // Handles serialization of references }, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, diff --git a/MCPForUnity/Editor/McpCiBoot.cs b/MCPForUnity/Editor/McpCiBoot.cs index c8e8c19eae..7fee43edc2 100644 --- a/MCPForUnity/Editor/McpCiBoot.cs +++ b/MCPForUnity/Editor/McpCiBoot.cs @@ -1,7 +1,5 @@ -using System; -using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Services.Transport.Transports; -using UnityEditor; namespace MCPForUnity.Editor { @@ -9,9 +7,12 @@ public static class McpCiBoot { public static void StartStdioForCi() { - try - { - EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false); + // Session-scoped, not EditorPrefs: this must not rewrite the developer's real + // transport preference, and it has to beat the value EditorConfigurationCache + // already read at domain load, which HttpAutoStartHandler consults on its first tick. + try + { + EditorConfigurationCache.Instance.PinStdioForSession(); } catch { /* ignore */ } diff --git a/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs new file mode 100644 index 0000000000..efb888d185 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Blender; +using MCPForUnity.Editor.Windows; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.MenuItems +{ + /// + /// Menu entry points that drive the same code path as the blender_bridge tool, so the + /// Blender handoff works without any AI client attached. Settings live in the Generative tab. + /// Actions are awaited so the editor stays responsive while Blender exports. + /// + public static class BlenderBridgeMenu + { + private const string Root = ProductInfo.MenuRoot + "/Blender Bridge/"; + + /// Exports Blender's current selection as GLB and places it in the open scene. + [MenuItem(Root + "Import Selection From Blender (GLB)", priority = 20)] + private static async void ImportSelection() + { + await RunAsync(new JObject { ["action"] = "import_model", ["selection_only"] = true, ["format"] = "glb" }); + } + + /// Exports Blender's whole scene as GLB and places it in the open scene. + [MenuItem(Root + "Import Whole Scene From Blender (GLB)", priority = 21)] + private static async void ImportScene() + { + await RunAsync(new JObject { ["action"] = "import_model", ["format"] = "glb", ["name"] = "BlenderScene" }); + } + + /// Captures Blender's viewport and reveals the PNG. + [MenuItem(Root + "Blender Viewport Screenshot", priority = 22)] + private static async void Screenshot() + { + JObject r = await RunAsync(new JObject { ["action"] = "screenshot" }); + string path = r?["data"]?["path"]?.ToString(); + if (!string.IsNullOrEmpty(path)) EditorUtility.RevealInFinder(path); + } + + /// Opens the MCP for Unity window; the Blender Bridge panel is in its Generative tab. + [MenuItem(Root + "Settings...", priority = 40)] + private static void OpenSettings() + { + MCPForUnityEditorWindow.ShowWindow(); + McpLog.Info("Blender Bridge settings are in the Generative tab of the MCP for Unity window."); + } + + /// Runs one bridge action and logs its full result. + private static async Task RunAsync(JObject parameters) + { + object result = await BlenderBridgeTool.HandleCommand(parameters); + JObject json = JObject.FromObject(result); + bool ok = json.Value("success") ?? false; + string text = json.ToString(Formatting.Indented); + if (ok) McpLog.Info($"[Blender Bridge] {parameters["action"]}: {json["message"]}\n{text}"); + else McpLog.Error($"[Blender Bridge] {parameters["action"]} failed: {json["error"]}\n{text}"); + return json; + } + } +} diff --git a/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta new file mode 100644 index 0000000000..b490058fb9 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf5a30eff9b34262b2e15cc23f196d12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs index e9fa9e674c..bb4b305151 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs @@ -56,6 +56,7 @@ public static class AssetGenJobManager internal static Func ImportOverrideForTests; internal static double PollIntervalSeconds = 3.0; internal static double TimeoutSeconds = 600.0; + internal static bool SkipModelVerificationForTests; private static readonly Dictionary Jobs = new(); private static readonly Dictionary Runners = new(); @@ -102,7 +103,15 @@ public static AssetGenJob StartModelGeneration(ModelGenRequest req) var runner = new Runner { Job = job, - SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + SubmitFn = async ct => + { + if (string.Equals(provider, "fal", StringComparison.OrdinalIgnoreCase) && !SkipModelVerificationForTests) + { + req.Model = AssetGenModelCatalog.ResolveModel("model", provider, req.Model); + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(req.Model, "model", req.Mode, ct, apiKey); + } + return await adapter.SubmitAsync(req, apiKey, transport, ct); + }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto, Transport = transport, @@ -132,7 +141,20 @@ public static AssetGenJob StartImageGeneration(ImageGenRequest req) var runner = new Runner { Job = job, - SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + SubmitFn = async ct => + { + if (!SkipModelVerificationForTests && string.Equals(provider, "fal", StringComparison.OrdinalIgnoreCase)) + { + req.Model = AssetGenModelCatalog.ResolveModel("image", provider, req.Model); + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(req.Model, "image", req.Mode, ct, apiKey); + } + if (!SkipModelVerificationForTests && string.Equals(provider, "openrouter", StringComparison.OrdinalIgnoreCase)) + { + req.Model = AssetGenModelCatalog.ResolveModel("image", provider, req.Model); + req.CatalogEntry = await OpenRouterModelCatalog.VerifyForGeneration(req.Model, req.Mode, ct); + } + return await adapter.SubmitAsync(req, apiKey, transport, ct); + }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)), Transport = transport, @@ -160,7 +182,15 @@ public static AssetGenJob StartAudioGeneration(AudioGenRequest req) var runner = new Runner { Job = job, - SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + SubmitFn = async ct => + { + if (!SkipModelVerificationForTests) + { + req.Model = AssetGenModelCatalog.ResolveModel("audio", provider, req.Model); + req.CatalogEntry = await FalModelCatalog.VerifyForGeneration(req.Model, "audio", "text", ct, apiKey); + } + return await adapter.SubmitAsync(req, apiKey, transport, ct); + }, PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), ImportFn = ImportOverrideForTests ?? AudioImportPipeline.ImportInto, Transport = transport, @@ -482,6 +512,13 @@ private static string WriteFile(Runner r, byte[] bytes) string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant(); if (!IsAllowedResultExtension(r.Job.Kind, ext)) throw new Exception($"provider returned a disallowed file type '.{ext}'"); + if (r.Job.Kind == "image") + { + string actual = ImageResultFormat.FromBytes(bytes); + if (actual == "webp") throw new Exception("Provider returned WebP, which this Unity image importer does not support. Choose a PNG/JPEG model."); + if (actual != null) ext = actual; + r.Job.Format = ext; + } string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder : (AssetGenPrefs.OutputRoot + "/" + r.Subfolder); if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root)) @@ -597,6 +634,8 @@ internal static void ResetForTests() ImportOverrideForTests = null; PollIntervalSeconds = 3.0; TimeoutSeconds = 600.0; + SkipModelVerificationForTests = false; + AssetGenModelCatalog.ResetForTests(); } } } diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs index ef25af0dae..5e7f33cb1e 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -31,15 +31,31 @@ public sealed class ModelEntry public float MinDurationSeconds; public bool Loopable; public string CommercialNote; // non-null => show a license caveat under the dropdown - public bool FromRefresh; // true => merged from a fal-catalog refresh (Phase 5) + public bool FromRefresh; + public string PromptField = "prompt"; + public bool DurationIsInteger = true; + public float DurationScale = 1f; // request units per second (e.g. milliseconds) + public string EditModelId; + public string ImageInputField = "image_urls"; + public bool ImageInputIsArray = true; + public bool SupportsNumImages = true; + public bool EditSupportsNumImages = true; + public bool SupportsImageSize = true; + public string LicenseType; + public string ModelUrl; + public string VerifiedAt; + public string OutputFormat; + public string EditOutputFormat; + public string[] Modes; + public string ModelOutputField; + public string TextureField; + public string RouterProviderTag; + public Newtonsoft.Json.Linq.JObject RouterParameters; } /// - /// Curated, always-present registry of selectable models per provider+kind, with metadata for - /// the Asset Generation panel. The first curated entry per (provider, kind) is the default, and - /// each default's references the owning adapter's constant directly - /// — so the panel's shown default always equals what an omitted model param resolves to - /// (a drift-guard test pins the two). A fal-catalog refresh overlay is layered on in Phase 5. + /// Shared registry for the panel and tools. Live fal and OpenRouter snapshots replace + /// bundled entries, including removals. Discovered entries are verified before generation. /// public static class AssetGenModelCatalog { @@ -57,49 +73,53 @@ public static class AssetGenModelCatalog new ModelEntry { Id = TripoAdapter.ModelVersion, Label = "Tripo v3.1", Provider = "tripo", Kind = "model", UseCase = "Text / image -> 3D" }, new ModelEntry { Id = "P1-20260311", Label = "Tripo P1 (premium)", Provider = "tripo", Kind = "model", UseCase = "Premium 3D" }, new ModelEntry { Id = MeshyAdapter.DefaultModel, Label = "Meshy 6", Provider = "meshy", Kind = "model", UseCase = "Text / image -> 3D" }, + new ModelEntry { Id = FalModelAdapter.DefaultModel, Label = "Hunyuan3D", Provider = "fal", Kind = "model", UseCase = "Text -> 3D", Modes = new[] { "text" } }, // Audio — fal (order: stable-audio, cassette SFX, cassette music, lyria). DurationField // is the request key each endpoint expects; null (Lyria) => prompt-only, no duration knob. - new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", PriceLabel = "$0.20/gen", MaxDurationSeconds = 190f, + new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", MaxDurationSeconds = 190f, DurationField = "seconds_total", DefaultDurationSeconds = 30f, - CommercialNote = "Free under $1M annual revenue (Stability Community License); an Enterprise license is required at or above $1M." }, - new ModelEntry { Id = "cassetteai/sound-effects-generator", Label = "CassetteAI SFX", Provider = "fal", Kind = "audio", UseCase = "Sound effects", PriceLabel = "$0.01/gen", MaxDurationSeconds = 30f, + CommercialNote = "Review the model's license and provider terms before commercial use." }, + new ModelEntry { Id = "cassetteai/sound-effects-generator", Label = "CassetteAI SFX", Provider = "fal", Kind = "audio", UseCase = "Sound effects", MaxDurationSeconds = 30f, DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f }, - new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f, + new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", MaxDurationSeconds = 180f, DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f }, - new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f }, + new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", MaxDurationSeconds = 30f }, }; - /// Curated entries for a provider+kind, in curated order (default first). Never null. + internal static IReadOnlyList Bundled(string provider, string kind) + => Curated.Where(e => Eq(e.Provider, provider) && Eq(e.Kind, kind)).ToArray(); + + /// Current entries for a provider+kind. Never null. public static IReadOnlyList ForProvider(string provider, string kind) { - var result = new List(); - foreach (ModelEntry e in Curated) - if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) result.Add(e); - return result; + if (Eq(provider, "fal") && FalModelCatalog.TryGet(kind, out var entries)) return entries; + if (Eq(provider, "openrouter") && Eq(kind, "image") && OpenRouterModelCatalog.TryGet(out var images)) return images; + return Bundled(provider, kind); } - /// The curated entry with this exact id, or null. + /// The current entry with this id, or null. public static ModelEntry Find(string id) { if (string.IsNullOrEmpty(id)) return null; - foreach (ModelEntry e in Curated) - if (Eq(e.Id, id)) return e; + foreach (string kind in new[] { "audio", "image", "model" }) + foreach (string provider in new[] { "fal", "openrouter", "tripo", "meshy" }) + foreach (ModelEntry e in ForProvider(provider, kind)) + if (Eq(e.Id, id)) return e; return null; } - /// The default model id for a provider+kind (the first curated entry), or null. + /// The first current entry for a provider+kind, or null. public static string DefaultModelId(string provider, string kind) { - foreach (ModelEntry e in Curated) - if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) return e.Id; - return null; + return ForProvider(provider, kind).FirstOrDefault(e => e.Modes == null || e.Modes.Contains("text"))?.Id; } /// /// The model id a generate_* tool should use: an explicit wins, - /// else the GUI-selected model for this (kind, provider), else the curated default. Null when - /// nothing resolves (the adapter then falls back to its own constant). Single home for the + /// else the GUI-selected model for this (kind, provider), else the catalog default. Missing + /// saved selections missing from a live catalog are rejected; explicit IDs are verified at submit. + /// Single home for the /// empty -> GUI-selected -> catalog-default precedence shared by all three generate tools. /// public static string ResolveModel(string kind, string provider, string requested) @@ -107,11 +127,20 @@ public static string ResolveModel(string kind, string provider, string requested string model = requested; if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel(kind, provider); if (string.IsNullOrWhiteSpace(model)) model = DefaultModelId(provider, kind); + bool authoritative = Eq(provider, "fal") && FalModelCatalog.Source(kind) != "bundled" + || Eq(provider, "openrouter") && kind == "image" && OpenRouterModelCatalog.Source != "bundled"; + var entries = ForProvider(provider, kind); + if (authoritative && string.IsNullOrWhiteSpace(requested) + && (string.IsNullOrWhiteSpace(model) || !entries.Any(e => Eq(e.Id, model)))) + throw new InvalidOperationException($"Model '{model}' is not in the current {kind} catalog. Refresh models and choose an available model; your saved selection has been preserved."); return string.IsNullOrWhiteSpace(model) ? null : model; } - /// Clears any test/refresh state. The refresh overlay is added in Phase 5; no-op today. - internal static void ResetForTests() { } + internal static void ResetForTests(bool isolate = false) + { + FalModelCatalog.ResetForTests(isolate); + OpenRouterModelCatalog.ResetForTests(isolate); + } private static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs new file mode 100644 index 0000000000..2d03eca3f6 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + /// + /// Public fal discovery, optionally using a configured key for higher rate limits. Each kind + /// is committed only after all metadata pages and the eager compatibility checks succeed. + /// Failures preserve the last successful snapshot. + /// Invoke on the editor thread; UnityWebRequest and its continuations stay on that thread. + /// + public static class FalModelCatalog + { + private const string ApiUrl = "https://api.fal.ai/v1/models"; + private const int EagerVerificationLimit = 5; + // Guards the editor thread against a runaway cache file. The live fal catalog is ~0.6 MB + // (~860 bytes per model), so this leaves ~25x headroom before reloads stop using the cache. + internal const long MaxCacheBytes = 16 * 1024 * 1024; + private static readonly TimeSpan Lifetime = TimeSpan.FromHours(24); + private static readonly Dictionary Snapshots = new(); + private static readonly Dictionary> Refreshes = new(); + private static readonly Dictionary Errors = new(); + private static readonly Dictionary Attempts = new(); + private static bool loaded; + private static readonly SemaphoreSlim RequestGate = new(1, 1); + private static DateTime nextRequestAt; + public static event Action Changed; + + internal static IHttpTransport TransportOverrideForTests; + internal static string CachePathOverrideForTests; + internal static Func UtcNow = () => DateTime.UtcNow; + internal static Func DelayOverrideForTests; + + private sealed class Snapshot + { + public string Kind; + public DateTime CheckedAt; + public List Entries; + [JsonIgnore] public bool FromDisk; + } + + private sealed class Cache + { + public int Version = 2; + public List Snapshots; + } + + public static bool IsRefreshing(string kind) => Refreshes.TryGetValue(kind, out var task) && !task.IsCompleted; + public static string LastError(string kind) => Errors.TryGetValue(kind, out var error) ? error : null; + + public static string Source(string kind) + { + Load(); + return Snapshots.TryGetValue(kind, out var snapshot) ? snapshot.FromDisk ? "cache" : "live" : "bundled"; + } + + public static string VerifiedAt(string kind) + { + Load(); + return Snapshots.TryGetValue(kind, out var snapshot) ? snapshot.CheckedAt.ToString("O") : null; + } + + public static bool IsStale(string kind) + { + Load(); + return !Snapshots.TryGetValue(kind, out var snapshot) || UtcNow() - snapshot.CheckedAt >= Lifetime; + } + + internal static bool TryGet(string kind, out IReadOnlyList entries) + { + Load(); + if (Snapshots.TryGetValue(kind, out var snapshot)) + { + entries = snapshot.Entries; + return true; + } + entries = null; + return false; + } + + /// Coalesces concurrent refreshes. Automatic retries back off for two minutes. + public static Task RefreshAsync(string kind, bool force = false) + { + if (kind != "audio" && kind != "image" && kind != "model") throw new ArgumentException("Unknown asset kind.", nameof(kind)); + Load(); + if (IsRefreshing(kind)) return Refreshes[kind]; + if (!force && !IsStale(kind)) return Task.FromResult(true); + if (!force && Attempts.TryGetValue(kind, out var attempted) && UtcNow() - attempted < TimeSpan.FromMinutes(2)) + return Task.FromResult(false); + Attempts[kind] = UtcNow(); + var task = RefreshCore(kind); + Refreshes[kind] = task; + return task; + } + + private static async Task RefreshCore(string kind) + { + string apiKey = null; + try + { + // Public discovery works without credentials; a configured key grants higher limits. + if (TransportOverrideForTests == null) + try { SecureKeyStore.Current.TryGet("fal", out apiKey); } catch { /* public fallback */ } + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + var http = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var all = await ListMetadata(kind, http, timeout.Token, apiKey); + var bundled = AssetGenModelCatalog.Bundled("fal", kind); + var preferred = bundled.Select(e => e.Id).ToList(); + string selected = AssetGenPrefs.GetSelectedModel(kind, "fal"); + if (FalModelSchema.SafeId(selected)) preferred.Insert(0, selected); + // Known profiles and user selections first; vendor highlights and recency order + // candidates. Recency is not presented as a measured quality score. + var candidates = all.Values + .Where(model => FalModelSchema.IsCandidate(model, kind)) + .OrderBy(model => preferred.Contains((string)model["endpoint_id"]) ? preferred.IndexOf((string)model["endpoint_id"]) : int.MaxValue) + .ThenByDescending(model => (bool?)model["metadata"]?["highlighted"] == true || (bool?)model["metadata"]?["pinned"] == true) + .ThenByDescending(model => (string)model["metadata"]?["updated_at"] ?? (string)model["metadata"]?["date"]) + .ThenBy(model => (string)model["endpoint_id"], StringComparer.Ordinal) + .ToArray(); + var eager = candidates.Take(EagerVerificationLimit + preferred.Count).ToArray(); + var ids = eager.Select(model => (string)model["endpoint_id"]).ToList(); + if (kind == "image") ids.AddRange(eager.Where(m => (string)m["metadata"]?["category"] == "text-to-image") + .Select(m => (string)m["endpoint_id"] + "/edit").Where(all.ContainsKey)); + var details = await Details(ids, http, timeout.Token, apiKey); + string checkedAt = UtcNow().ToString("O"); + var entries = new List(); + foreach (var candidate in candidates) + { + string id = (string)candidate["endpoint_id"]; + // Keep the complete lightweight catalog. Expand only a small recommended + // set; all other endpoints are checked on selection or before generation. + if (!eager.Contains(candidate)) { entries.Add(FalModelSchema.Discover(candidate, kind)); continue; } + if (!details.TryGetValue(id, out var model)) continue; + var entry = FalModelSchema.Parse(model, kind, checkedAt); + if (entry == null) continue; + if (kind == "image" && details.TryGetValue(id + "/edit", out var editModel)) + { + var edit = FalModelSchema.Parse(editModel, "image", checkedAt, edit: true); + if (edit != null) + { + entry.EditModelId = edit.Id; + entry.ImageInputField = edit.ImageInputField; + entry.ImageInputIsArray = edit.ImageInputIsArray; + entry.EditSupportsNumImages = edit.SupportsNumImages; + entry.EditOutputFormat = edit.OutputFormat; + entry.Modes = new[] { "text", "image" }; + } + } + entries.Add(entry); + } + // A selected non-default model must not silently become the automatic default. + var order = bundled.Select((entry, index) => new { entry.Id, Index = index }).ToDictionary(entry => entry.Id, entry => entry.Index); + var ordered = entries.OrderBy(entry => order.TryGetValue(entry.Id, out int index) ? index : int.MaxValue).ToList(); + Snapshots[kind] = new Snapshot { Kind = kind, CheckedAt = UtcNow(), Entries = ordered }; + Errors.Remove(kind); + Save(); + Changed?.Invoke(kind); + return true; + } + catch (Exception error) + { + Errors[kind] = error is OperationCanceledException ? "Model refresh timed out. Previous catalog retained." : SecretRedactor.Scrub(error.Message, apiKey); + return false; + } + } + + private static async Task> ListMetadata(string kind, IHttpTransport http, CancellationToken ct, string apiKey = null) + { + var models = new Dictionary(StringComparer.Ordinal); + var categories = kind == "audio" ? new[] { "text-to-audio" } + : kind == "model" ? new[] { "text-to-3d", "image-to-3d" } : new[] { "text-to-image", "image-to-image" }; + foreach (string category in categories) + { + var cursors = new HashSet(); + string cursor = null; + for (int page = 0; page < 50; page++) + { + string url = ApiUrl + "?category=" + category + "&status=active&limit=100"; + if (cursor != null) url += "&cursor=" + Uri.EscapeDataString(cursor); + var json = await Get(url, http, ct, apiKey); + foreach (JObject model in (JArray)json["models"]) + { + string id = (string)model["endpoint_id"]; + if (FalModelSchema.SafeId(id) && (string)model["metadata"]?["status"] == "active" + && (string)model["metadata"]?["category"] == category) models[id] = model; + } + if ((bool?)json["has_more"] != true && string.IsNullOrEmpty((string)json["next_cursor"])) break; + cursor = (string)json["next_cursor"]; + if (string.IsNullOrEmpty(cursor) || !cursors.Add(cursor)) throw new InvalidOperationException("Incomplete model pagination. Previous catalog retained."); + if (page == 49) throw new InvalidOperationException("Model pagination exceeded its limit. Previous catalog retained."); + } + } + return models; + } + + private static async Task> Details(IEnumerable ids, IHttpTransport http, CancellationToken ct, string apiKey = null, bool foreground = false) + { + var result = new Dictionary(StringComparer.Ordinal); + string[] wanted = ids.Distinct(StringComparer.Ordinal).Where(FalModelSchema.SafeId).ToArray(); + // Expanded schemas are large; small batches respect the provider's expansion limits. + for (int offset = 0; offset < wanted.Length; offset += 5) + { + string query = string.Join("&", wanted.Skip(offset).Take(5).Select(id => "endpoint_id=" + Uri.EscapeDataString(id))); + var batch = wanted.Skip(offset).Take(5).ToArray(); + string cursor = null; + var cursors = new HashSet(); + for (int page = 0; page < 10; page++) + { + string url = ApiUrl + "?" + query + "&expand=openapi-3.0&limit=5"; + if (cursor != null) url += "&cursor=" + Uri.EscapeDataString(cursor); + var json = await Get(url, http, ct, apiKey, allowMissing: true, foreground: foreground); + // fal can return a batch-level 404 when even one endpoint is missing. + // Check individual IDs so one retired alias cannot hide active models. + if (((JArray)json["models"]).Count == 0 && batch.Length > 1 && cursor == null) + { + foreach (string id in batch) + foreach (var item in await Details(new[] { id }, http, ct, apiKey, foreground)) result[item.Key] = item.Value; + break; + } + foreach (JObject model in (JArray)json["models"]) + { + string id = (string)model["endpoint_id"]; + if (!wanted.Contains(id, StringComparer.Ordinal)) throw new InvalidOperationException("Unexpected model in schema response. Previous catalog retained."); + if ((string)model["metadata"]?["status"] == "active" && (!(model["openapi"] is JObject api) || api["error"] != null)) + throw new InvalidOperationException("Model schema expansion failed. Previous catalog retained."); + result[id] = model; + } + if ((bool?)json["has_more"] != true && string.IsNullOrEmpty((string)json["next_cursor"])) break; + cursor = (string)json["next_cursor"]; + if (page == 9 || string.IsNullOrEmpty(cursor) || !cursors.Add(cursor)) + throw new InvalidOperationException("Incomplete model schema pagination. Previous catalog retained."); + } + } + return result; + } + + /// + /// Background refreshes take turns through . A foreground request + /// (a generation or selection check) skips that queue so it never waits behind a full catalog + /// refresh; without a key it still keeps the public pacing interval. + /// + private static async Task Get(string url, IHttpTransport http, CancellationToken ct, string apiKey = null, bool allowMissing = false, bool foreground = false) + { + ProviderHttp.RequireHost(url, "api.fal.ai", apiKey, "fal catalog"); + HttpResult response = null; + for (int attempt = 0; attempt < 3; attempt++) + { + if (!foreground) await RequestGate.WaitAsync(ct); + try + { + TimeSpan wait = nextRequestAt - UtcNow(); + if (wait > TimeSpan.Zero && (!foreground || string.IsNullOrEmpty(apiKey))) await Delay(wait, ct); + ct.ThrowIfCancellationRequested(); + var request = new HttpRequestSpec { Method = "GET", Url = url }; + request.Headers["User-Agent"] = "MCPForUnity/ModelCatalog"; + if (!string.IsNullOrEmpty(apiKey)) request.Headers["Authorization"] = "Key " + apiKey; + response = await http.SendAsync(request, ct); + // Public discovery has a much smaller allowance than authenticated calls. + // Keep background refreshes from exhausting it before a preflight query. + nextRequestAt = UtcNow().AddSeconds(string.IsNullOrEmpty(apiKey) ? 7 : 1); + } + finally { if (!foreground) RequestGate.Release(); } + if (response?.Status != 429 || attempt == 2) break; + await Delay(TimeSpan.FromSeconds(Math.Min(10, Math.Max(1, response.RetryAfterSeconds ?? (2 << attempt)))), ct); + } + // Find mode returns 404 when every requested endpoint is absent. In particular, + // missing edit endpoints are an expected negative discovery result, not an outage. + if (allowMissing && response?.Status == 404) + { + var missing = JObject.Parse(ProviderHttp.BodyText(response)); + if ((string)missing["error"]?["type"] == "not_found") + return new JObject { ["models"] = new JArray(), ["has_more"] = false }; + } + // Do not persist provider error bodies or credentials. + if (response == null || response.Status < 200 || response.Status >= 300) + throw new InvalidOperationException($"Model catalog request failed (HTTP {response?.Status}). Previous catalog retained."); + var json = JObject.Parse(ProviderHttp.BodyText(response)); + if (!(json["models"] is JArray)) throw new InvalidOperationException("Invalid model catalog response. Previous catalog retained."); + return json; + } + + private static Task Delay(TimeSpan duration, CancellationToken ct) + => DelayOverrideForTests?.Invoke(duration, ct) ?? Task.Delay(duration, ct); + + /// Recheck the exact endpoint before a paid submit, and capture its live profile. + internal static async Task VerifyForGeneration(string id, string kind, string mode, CancellationToken ct, string apiKey = null) + { + if (!FalModelSchema.SafeId(id)) throw new InvalidOperationException("Invalid fal model ID."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + var known = AssetGenModelCatalog.ForProvider("fal", kind).FirstOrDefault(e => e.Id == id); + bool directImage = known?.Modes?.SequenceEqual(new[] { "image" }) == true; + var ids = kind == "image" && mode == "image" && !directImage ? new[] { id, id + "/edit" } : new[] { id }; + var models = await Details(ids, TransportOverrideForTests ?? new UnityWebRequestTransport(), timeout.Token, apiKey, foreground: true); + if (!models.TryGetValue(id, out var model)) throw new InvalidOperationException($"Model '{id}' is unavailable. Refresh models and choose another model."); + var entry = FalModelSchema.Parse(model, kind, UtcNow().ToString("O")); + if (entry == null) throw new InvalidOperationException($"Model '{id}' is unavailable or incompatible with this tool. Refresh models and choose another model."); + if (kind == "image" && mode == "image" && !entry.Modes.Contains("image")) + { + var edit = models.TryGetValue(id + "/edit", out var editModel) ? FalModelSchema.Parse(editModel, "image", entry.VerifiedAt, edit: true) : null; + if (edit == null) throw new InvalidOperationException($"Model '{id}' has no compatible image editing endpoint."); + entry.EditModelId = edit.Id; + entry.ImageInputField = edit.ImageInputField; + entry.ImageInputIsArray = edit.ImageInputIsArray; + entry.EditSupportsNumImages = edit.SupportsNumImages; + entry.EditOutputFormat = edit.OutputFormat; + entry.Modes = new[] { "text", "image" }; + } + if (!entry.Modes.Contains(string.IsNullOrEmpty(mode) ? "text" : mode)) + throw new InvalidOperationException($"Model '{id}' does not support '{mode}' mode."); + Load(); + if (Snapshots.TryGetValue(kind, out var snapshot)) + { + int index = snapshot.Entries.FindIndex(e => e.Id == id); + if (index >= 0) { snapshot.Entries[index] = entry; Save(); Changed?.Invoke(kind); } + } + return entry; + } + + private static string CachePath => CachePathOverrideForTests ?? Path.Combine(Path.GetDirectoryName(Application.dataPath), "Library", "MCPForUnity", "fal-model-catalog.json"); + + private static void Load() + { + if (loaded) return; + loaded = true; + try + { + if (!File.Exists(CachePath)) return; + if (new FileInfo(CachePath).Length > MaxCacheBytes) + { + McpLog.Warn($"fal model cache exceeds {MaxCacheBytes / (1024 * 1024)} MB and was ignored; models are re-fetched after each reload."); + return; + } + var cache = JsonConvert.DeserializeObject(File.ReadAllText(CachePath)); + if (cache?.Version != 2 || cache.Snapshots == null) return; + foreach (var snapshot in cache.Snapshots) + { + if ((snapshot.Kind != "audio" && snapshot.Kind != "image" && snapshot.Kind != "model") || snapshot.Entries == null + || snapshot.CheckedAt.Kind != DateTimeKind.Utc || snapshot.CheckedAt > UtcNow().AddMinutes(5) + || snapshot.Entries.Any(e => e == null || e.Provider != "fal" || e.Kind != snapshot.Kind || !FalModelSchema.SafeId(e.Id))) continue; + snapshot.FromDisk = true; + Snapshots[snapshot.Kind] = snapshot; + } + } + catch { /* Corrupt or old caches are ignored; never erase the bundled catalog. */ } + } + + private static void Save() + { + try + { + string path = CachePath; + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path + ".tmp", JsonConvert.SerializeObject(new Cache { Snapshots = Snapshots.Values.ToList() })); + if (File.Exists(path)) File.Replace(path + ".tmp", path, null); + else File.Move(path + ".tmp", path); + } + catch { /* A read-only/full disk does not invalidate the in-memory refresh. */ } + } + + internal static void ReloadCacheForTests() + { + Snapshots.Clear(); + loaded = false; + } + + internal static void ResetForTests(bool isolate = false) + { + Snapshots.Clear(); + Refreshes.Clear(); + Errors.Clear(); + Attempts.Clear(); + loaded = false; + TransportOverrideForTests = null; + CachePathOverrideForTests = isolate ? Path.Combine(Path.GetTempPath(), "unused_fal_catalog_" + Guid.NewGuid().ToString("N"), "catalog.json") : null; + UtcNow = () => DateTime.UtcNow; + nextRequestAt = DateTime.MinValue; + DelayOverrideForTests = isolate ? (_, __) => Task.CompletedTask : (Func)null; + Changed = null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta new file mode 100644 index 0000000000..2dfa4d8154 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a09083e5f5574e6a89e3265c845cb2d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs new file mode 100644 index 0000000000..2ee7c19c74 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs @@ -0,0 +1,229 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + /// + /// Conservative compatibility gate for fal's queue OpenAPI schemas. Only local references + /// and the request/response shapes understood by our adapters are accepted. An unknown + /// schema cannot pass preflight; metadata discovery alone never authorizes a paid submit. + /// + internal static class FalModelSchema + { + internal static bool SafeId(string id) + => !string.IsNullOrEmpty(id) && id.Length <= 200 + && Regex.IsMatch(id, @"^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_.-]+)+$") + && !id.Split('/').Any(part => part == "." || part == ".."); + + internal static bool IsCandidate(JObject model, string kind) + { + if ((string)model?["metadata"]?["status"] != "active") return false; + string category = (string)model?["metadata"]?["category"]; + if (kind == "model") return category == "text-to-3d" || category == "image-to-3d"; + if (kind == "image") return (category == "text-to-image" || category == "image-to-image") + && !((string)model?["endpoint_id"] ?? "").Contains("text-to-vector"); + return (string)model?["metadata"]?["category"] == "text-to-audio" && AudioUseCase(model) != null; + } + + private static string AudioUseCase(JObject model) + { + string signals = ((string)model["endpoint_id"] + " " + string.Join(" ", model["metadata"]?["tags"]?.Values() ?? Enumerable.Empty())).ToLowerInvariant(); + if (signals.Contains("tts") || signals.Contains("speech") || signals.Contains("voice")) return null; + if (signals.Contains("stable-audio")) return "Music + SFX"; + if (signals.Contains("sound-effect") || signals.Contains("sfx")) return "Sound effects"; + if (signals.Contains("music") || signals.Contains("lyria")) return "Background music"; + return "Audio"; + } + + internal static ModelEntry Discover(JObject model, string kind) + { + if (!IsCandidate(model, kind) || !SafeId((string)model["endpoint_id"])) return null; + string id = (string)model["endpoint_id"]; + var metadata = model["metadata"]; + return new ModelEntry + { + Id = id, Provider = "fal", Kind = kind, Label = (string)metadata["display_name"] ?? id, + UseCase = kind == "audio" ? AudioUseCase(model) : (string)metadata["category"], + FromRefresh = true, Modes = new[] { ((string)metadata["category"]).StartsWith("image-") ? "image" : "text" }, + LicenseType = (string)metadata["license_type"], ModelUrl = "https://fal.ai/models/" + id, + CommercialNote = "Review the model's license and provider terms before commercial use.", + }; + } + + internal static ModelEntry Parse(JObject model, string kind, string verifiedAt, bool edit = false) + { + string id = (string)model?["endpoint_id"]; + var metadata = model?["metadata"] as JObject; + if (!SafeId(id) || (string)metadata?["status"] != "active") return null; + string category = (string)metadata?["category"]; + if (!IsCandidate(model, kind) || edit && category != "image-to-image") return null; + bool imageInputRequired = category.StartsWith("image-", StringComparison.Ordinal); + + var api = model["openapi"] as JObject; + var paths = api?["paths"] as JObject; + if (paths == null) return null; + // fal may expose an alias ID whose canonical OpenAPI path differs from that ID. + var submissions = paths.Properties().Where(p => p.Value["post"] != null).ToArray(); + if (submissions.Length != 1) return null; + var input = Resolve(api, submissions[0].Value["post"]?["requestBody"]?["content"]?["application/json"]?["schema"]); + var resultPath = paths.Properties().FirstOrDefault(p => p.Name.EndsWith("/requests/{request_id}", StringComparison.Ordinal)); + var output = Resolve(api, resultPath?.Value["get"]?["responses"]?["200"]?["content"]?["application/json"]?["schema"]); + var properties = input?["properties"] as JObject; + if (properties == null || input["allOf"] != null || input["oneOf"] != null || input["anyOf"] != null) return null; + + string promptField = properties["prompt"] != null ? "prompt" : kind == "audio" && properties["text"] != null ? "text" : null; + if (promptField == null && !imageInputRequired || promptField != null && !IsString(api, properties[promptField])) return null; + + var entry = new ModelEntry + { + Id = id, Provider = "fal", Kind = kind, + Label = (string)metadata["display_name"] ?? id, + PromptField = promptField, FromRefresh = true, VerifiedAt = verifiedAt, + LicenseType = (string)metadata["license_type"], + ModelUrl = "https://fal.ai/models/" + id, + CommercialNote = "Review the model's license and provider terms before commercial use.", + Modes = new[] { imageInputRequired ? "image" : "text" }, + SupportsNumImages = (string)Resolve(api, properties["num_images"])?["type"] == "integer" + && AcceptsOne(Resolve(api, properties["num_images"])), + SupportsImageSize = SupportsDimensions(api, properties["image_size"]), + }; + + if (kind == "audio") + { + entry.UseCase = AudioUseCase(model); + if (entry.UseCase == null) return null; + if (!HasFile(api, output, "audio") && !HasFile(api, output, "audio_file") + && (string)Resolve(api, output?["properties"]?["audio_url"])?["type"] != "string") return null; + + foreach (string field in new[] { "seconds_total", "duration", "duration_seconds", "music_length_ms" }) + { + if (properties[field] == null) continue; + var duration = Numeric(api, properties[field]); + if (duration == null || duration["exclusiveMinimum"] != null || duration["exclusiveMaximum"] != null) return null; + float scale = field == "music_length_ms" ? 1000f : 1f; + float min = (float?)duration["minimum"] ?? 1f * scale; + float max = (float?)duration["maximum"] ?? 0f; + if (!Finite(min) || !Finite(max) || min < 0 || max <= 0 || min > max) return null; + entry.DurationField = field; + entry.DurationScale = scale; + entry.DurationIsInteger = (string)duration["type"] == "integer"; + entry.MinDurationSeconds = min / scale; + entry.MaxDurationSeconds = max / scale; + // Keep the interactive default short, while respecting the live schema. + float defaultSeconds = ((float?)duration["default"] ?? 10f * scale) / scale; + if (!Finite(defaultSeconds)) return null; + entry.DefaultDurationSeconds = Math.Min(max / scale, Math.Max(min / scale, Math.Min(30f, defaultSeconds))); + break; + } + } + else if (kind == "model") + { + entry.UseCase = imageInputRequired ? "Image -> 3D" : "Text -> 3D"; + entry.ModelOutputField = HasFile(api, output, "model_glb") ? "model_glb" + : HasFile(api, Resolve(api, output?["properties"]?["model_urls"]), "glb") ? "model_urls.glb" : null; + if (entry.ModelOutputField == null) return null; + if ((string)Resolve(api, properties["texture"])?["type"] == "boolean") entry.TextureField = "texture"; + } + else + { + entry.UseCase = edit ? "Image editing" : "General image"; + if (properties["output_format"] != null) + { + var format = Resolve(api, properties["output_format"]); + var formats = format?["enum"]?.Values().ToArray(); + entry.OutputFormat = formats?.Contains("png") == true ? "png" + : formats?.Contains("jpeg") == true ? "jpeg" : formats?.Contains("jpg") == true ? "jpg" : null; + if (entry.OutputFormat == null) return null; + } + var images = Resolve(api, output?["properties"]?["images"]); + bool imageArray = (string)images?["type"] == "array" + && (string)Resolve(api, Resolve(api, images["items"])?["properties"]?["url"])?["type"] == "string"; + if (!imageArray && !HasFile(api, output, "image")) return null; + } + if (imageInputRequired) + { + string field = properties["image_urls"] != null ? "image_urls" : properties["image_url"] != null ? "image_url" : null; + var imageInput = Resolve(api, field == null ? null : properties[field]); + bool array = (string)imageInput?["type"] == "array" && (string)Resolve(api, imageInput["items"])?["type"] == "string"; + if (field == null || (!array && (string)imageInput?["type"] != "string") + || array && (((int?)imageInput["minItems"] ?? 1) > 1 || ((int?)imageInput["maxItems"] ?? 1) < 1)) return null; + entry.ImageInputField = field; + entry.ImageInputIsArray = array; + if (kind == "image") + { + entry.EditModelId = id; + entry.EditSupportsNumImages = entry.SupportsNumImages; + entry.EditOutputFormat = entry.OutputFormat; + } + } + + foreach (string required in input["required"]?.Values() ?? Enumerable.Empty()) + { + if (required == promptField || required == entry.DurationField || imageInputRequired && required == entry.ImageInputField) continue; + if (kind == "model" && required == entry.TextureField) continue; + if (kind == "image" && required == "num_images" && entry.SupportsNumImages) continue; + if (kind == "image" && required == "output_format" && entry.OutputFormat != null) continue; + return null; + } + return entry; + } + + private static bool Finite(float number) => !float.IsNaN(number) && !float.IsInfinity(number); + + private static bool IsString(JObject api, JToken token) + { + var schema = Resolve(api, token); + return (string)schema?["type"] == "string" || schema?["anyOf"] is JArray options + && options.Any(option => (string)Resolve(api, option)?["type"] == "string"); + } + + private static bool AcceptsOne(JObject schema) + => schema != null && ((float?)schema["minimum"] ?? 1f) <= 1f + && ((float?)schema["maximum"] ?? 1f) >= 1f && schema["enum"] == null; + + private static bool SupportsDimensions(JObject api, JToken token, int depth = 0) + { + if (depth >= 8) return false; + var schema = Resolve(api, token); + if (schema == null) return false; + if (schema["anyOf"] is JArray options) return options.Any(option => SupportsDimensions(api, option, depth + 1)); + return (string)Resolve(api, schema["properties"]?["width"])?["type"] == "integer" + && (string)Resolve(api, schema["properties"]?["height"])?["type"] == "integer"; + } + + private static bool HasFile(JObject api, JObject output, string field) + => (string)Resolve(api, Resolve(api, output?["properties"]?[field])?["properties"]?["url"])?["type"] == "string"; + + private static JObject Numeric(JObject api, JToken token, int depth = 0) + { + if (depth >= 8) return null; + var schema = Resolve(api, token); + if (schema == null) return null; + string type = (string)schema["type"]; + if (type == "integer" || type == "number") return schema; + return (schema["anyOf"] as JArray)?.Select(option => Numeric(api, option, depth + 1)).FirstOrDefault(option => option != null); + } + + private static JObject Resolve(JObject api, JToken token, int depth = 0) + { + if (depth >= 8) return null; + var schema = token as JObject; + while (schema?["$ref"] != null) + { + if (++depth >= 8) return null; + string reference = (string)schema["$ref"]; + const string prefix = "#/components/schemas/"; + if (!reference.StartsWith(prefix, StringComparison.Ordinal)) return null; + schema = api?["components"]?["schemas"]?[reference.Substring(prefix.Length)] as JObject; + } + if (schema?["anyOf"] is JArray options) + { + var nonNull = options.Where(option => (string)option["type"] != "null").ToArray(); + if (nonNull.Length == 1) return Resolve(api, nonNull[0], depth + 1); + } + return schema?["$ref"] == null ? schema : null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta new file mode 100644 index 0000000000..4d69955075 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/FalModelSchema.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26ab28b6bf5f41c5911d38bf1e4e1610 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs index 2b654c2b7d..561ee1a3bf 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs @@ -12,6 +12,7 @@ public sealed class HttpResult public byte[] Body; public string Text; public bool IsSuccess; + public int? RetryAfterSeconds; /// True when the transport reports success or the status code is 2xx. public bool Ok => IsSuccess || (Status >= 200 && Status < 300); diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs index 5b0a0f1002..ce135375b3 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs @@ -63,7 +63,8 @@ public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) Status = (int)request.responseCode, Body = request.downloadHandler?.data, Text = request.downloadHandler?.text, - IsSuccess = request.result == UnityWebRequest.Result.Success + IsSuccess = request.result == UnityWebRequest.Result.Success, + RetryAfterSeconds = int.TryParse(request.GetResponseHeader("Retry-After"), out int retryAfter) ? (int?)retryAfter : null }; tcs.TrySetResult(result); } diff --git a/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs new file mode 100644 index 0000000000..eccc442d63 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + internal static class ImageResultFormat + { + internal static string FromMetadata(string mediaType, string url = null) + { + switch (mediaType?.Split(';')[0].ToLowerInvariant()) + { + case "image/png": return "png"; + case "image/jpeg": return "jpg"; + case "image/webp": return "webp"; + case "image/svg+xml": return "svg"; + } + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + string ext = Path.GetExtension(uri.AbsolutePath).TrimStart('.').ToLowerInvariant(); + if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp" || ext == "svg") return ext; + } + return null; + } + + internal static string FromBytes(byte[] bytes) + { + if (bytes == null) return null; + if (bytes.Length >= 8 && bytes[0] == 137 && bytes[1] == 80 && bytes[2] == 78 && bytes[3] == 71 + && bytes[4] == 13 && bytes[5] == 10 && bytes[6] == 26 && bytes[7] == 10) return "png"; + if (bytes.Length >= 3 && bytes[0] == 255 && bytes[1] == 216 && bytes[2] == 255) return "jpg"; + if (bytes.Length >= 12 && bytes[0] == 82 && bytes[1] == 73 && bytes[2] == 70 && bytes[3] == 70 + && bytes[8] == 87 && bytes[9] == 69 && bytes[10] == 66 && bytes[11] == 80) return "webp"; + return null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta new file mode 100644 index 0000000000..7e882bc6a8 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/ImageResultFormat.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a451fd8da1044900960fc42405b91af0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs new file mode 100644 index 0000000000..92e2b79ea7 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + public static class OpenRouterModelCatalog + { + private const string Api = "https://openrouter.ai/api/v1/images/models"; + private static readonly TimeSpan Lifetime = TimeSpan.FromHours(24); + private sealed class Snapshot + { + public int Version = 1; + public DateTime CheckedAt; + public List Entries; + } + private static Snapshot snapshot; + private static bool loaded, fromDisk, isolated; + private static Task refresh; + private static DateTime attempted; + public static event Action Changed; + public static string LastError { get; private set; } + internal static IHttpTransport TransportOverrideForTests; + internal static string CachePathOverrideForTests; + internal static Func UtcNow = () => DateTime.UtcNow; + private static string CachePath => CachePathOverrideForTests ?? Path.Combine(Path.GetDirectoryName(Application.dataPath), "Library", "MCPForUnity", "openrouter-image-catalog.json"); + + public static bool IsRefreshing => refresh != null && !refresh.IsCompleted; + public static string Source { get { Load(); return snapshot == null ? "bundled" : fromDisk ? "cache" : "live"; } } + public static string CheckedAt { get { Load(); return snapshot?.CheckedAt.ToString("O"); } } + public static bool IsStale { get { Load(); return snapshot == null || UtcNow() - snapshot.CheckedAt >= Lifetime; } } + internal static bool TryGet(out IReadOnlyList entries) { Load(); entries = snapshot?.Entries; return entries != null; } + + public static Task RefreshAsync(bool force = false) + { + Load(); + if (IsRefreshing) return refresh; + if (!force && !IsStale) return Task.FromResult(true); + if (isolated && TransportOverrideForTests == null) return Task.FromResult(false); + if (!force && UtcNow() - attempted < TimeSpan.FromMinutes(2)) return Task.FromResult(false); + attempted = UtcNow(); + return refresh = RefreshCore(); + } + + private static async Task RefreshCore() + { + try + { + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var entries = await Discover(ct.Token); + snapshot = new Snapshot { CheckedAt = UtcNow(), Entries = entries }; + fromDisk = false; + LastError = null; + Save(); + Changed?.Invoke(); + return true; + } + catch (Exception error) + { + LastError = error is OperationCanceledException ? "OpenRouter catalog refresh timed out. Previous catalog retained." : error.Message; + return false; + } + } + + private static async Task> Discover(CancellationToken ct) + { + var json = await Get(Api, ct); + if (!(json["data"] is JArray data)) throw new InvalidOperationException("Invalid OpenRouter model catalog."); + var entries = new List(); + foreach (JObject model in data) + { + string id = (string)model["id"]; + if (!FalModelSchema.SafeId(id) || model["architecture"]?["output_modalities"]?.Values().Contains("image") != true + || model["architecture"]?["input_modalities"]?.Values().Contains("text") != true) continue; + var parameters = model["supported_parameters"] as JObject; + if (parameters == null || !RasterFormat(parameters, out _)) continue; + var modes = Modes(parameters); + if (modes.Length == 0) continue; + entries.Add(new ModelEntry + { + Id = id, Label = (string)model["name"] ?? id, Kind = "image", Provider = "openrouter", + FromRefresh = true, Modes = modes, UseCase = "Image generation", RouterParameters = parameters, + ModelUrl = "https://openrouter.ai/" + id, + }); + } + var bundled = AssetGenModelCatalog.Bundled("openrouter", "image").Select(e => e.Id).ToArray(); + return entries.GroupBy(e => e.Id).Select(g => g.First()).OrderBy(e => bundled.Contains(e.Id) ? 0 : 1).ToList(); + } + + internal static async Task VerifyForGeneration(string id, string mode, CancellationToken ct) + { + if (!FalModelSchema.SafeId(id)) throw new InvalidOperationException("Invalid OpenRouter model ID."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + var entry = (await Discover(timeout.Token)).FirstOrDefault(e => e.Id == id); + if (entry == null) throw new InvalidOperationException($"OpenRouter model '{id}' is unavailable or incompatible. Refresh models and choose another model."); + mode = string.IsNullOrEmpty(mode) ? "text" : mode; + var json = await Get(Api + "/" + id + "/endpoints", timeout.Token); + if ((string)json["id"] != id || !(json["endpoints"] is JArray endpoints)) + throw new InvalidOperationException("Invalid OpenRouter endpoint catalog."); + foreach (JObject endpoint in endpoints) + { + var parameters = endpoint["supported_parameters"] as JObject; + string tag = (string)endpoint["provider_tag"]; + // Pin the chosen capabilities; a fallback provider may accept different fields. + if (parameters == null || string.IsNullOrEmpty(tag) || !Modes(parameters).Contains(mode) + || !RasterFormat(parameters, out string format)) continue; + entry.RouterProviderTag = tag; + entry.RouterParameters = parameters; + entry.OutputFormat = format; + entry.Modes = Modes(parameters); + entry.VerifiedAt = UtcNow().ToString("O"); + Load(); + int index = snapshot?.Entries.FindIndex(e => e.Id == id) ?? -1; + if (index >= 0) { snapshot.Entries[index] = entry; Save(); Changed?.Invoke(); } + return entry; + } + throw new InvalidOperationException($"OpenRouter model '{id}' has no compatible '{mode}' endpoint."); + } + + private static string[] Modes(JObject parameters) + { + var reference = parameters["input_references"]; + int minimum = (int?)reference?["min"] ?? 0, maximum = (int?)reference?["max"] ?? 0; + var modes = new List(); + if (minimum == 0) modes.Add("text"); + if (reference != null && minimum <= 1 && maximum >= 1) modes.Add("image"); + return modes.ToArray(); + } + + private static bool RasterFormat(JObject parameters, out string format) + { + format = null; + if (parameters["output_format"] == null) return true; + var values = parameters["output_format"]?["values"]?.Values().ToArray(); + format = new[] { "png", "jpeg", "jpg" }.FirstOrDefault(f => values?.Contains(f) == true); + return format != null; + } + + private static async Task Get(string url, CancellationToken ct) + { + var result = await (TransportOverrideForTests ?? new UnityWebRequestTransport()).SendAsync(new HttpRequestSpec { Method = "GET", Url = url }, ct); + if (result == null || result.Status < 200 || result.Status >= 300) + throw new InvalidOperationException($"OpenRouter catalog request failed (HTTP {result?.Status}). Previous catalog retained."); + return JObject.Parse(ProviderHttp.BodyText(result)); + } + + private static void Load() + { + if (loaded) return; + loaded = true; + try + { + if (!File.Exists(CachePath)) return; + if (new FileInfo(CachePath).Length > FalModelCatalog.MaxCacheBytes) + { + McpLog.Warn($"OpenRouter model cache exceeds {FalModelCatalog.MaxCacheBytes / (1024 * 1024)} MB and was ignored; models are re-fetched after each reload."); + return; + } + var candidate = JsonConvert.DeserializeObject(File.ReadAllText(CachePath)); + if (candidate?.Version != 1 || candidate.Entries == null || candidate.CheckedAt.Kind != DateTimeKind.Utc + || candidate.CheckedAt > UtcNow().AddMinutes(5) + || candidate.Entries.Any(e => e == null || e.Kind != "image" || e.Provider != "openrouter" || !FalModelSchema.SafeId(e.Id))) return; + snapshot = candidate; + fromDisk = true; + } + catch { /* Ignore corrupt caches. */ } + } + + private static void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(CachePath)); + File.WriteAllText(CachePath + ".tmp", JsonConvert.SerializeObject(snapshot)); + if (File.Exists(CachePath)) File.Replace(CachePath + ".tmp", CachePath, null); + else File.Move(CachePath + ".tmp", CachePath); + } + catch { /* The in-memory snapshot still works. */ } + } + + internal static void ReloadCacheForTests() { snapshot = null; loaded = false; } + internal static void ResetForTests(bool isolate = false) + { + snapshot = null; loaded = false; fromDisk = false; isolated = isolate; refresh = null; LastError = null; + attempted = DateTime.MinValue; TransportOverrideForTests = null; Changed = null; UtcNow = () => DateTime.UtcNow; + CachePathOverrideForTests = isolate ? Path.Combine(Path.GetTempPath(), "unused_or_catalog_" + Guid.NewGuid().ToString("N"), "catalog.json") : null; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta new file mode 100644 index 0000000000..8f43977a66 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/OpenRouterModelCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4abab199794e4abf89ac9767e2620940 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs index 026de16b34..49cb79083e 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs @@ -20,6 +20,8 @@ public static IModelProviderAdapter Model(string id) return new TripoAdapter(); case "meshy": return new MeshyAdapter(); + case "fal": + return new FalModelAdapter(); default: throw new NotSupportedException($"Unknown model provider '{id}'."); } @@ -66,10 +68,11 @@ public static IReadOnlyList List() { new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } }, new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "fal", Kind = "model", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } }, new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } }, - // fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key. + // fal's three asset kinds share one secure-store key. new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } }, }; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs index 35ec7ce650..a6e2ab1fac 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -18,8 +18,7 @@ public sealed class FalAdapter : IImageProviderAdapter { private const string QueueBase = "https://queue.fal.run/"; private const string QueueHost = "queue.fal.run"; - // FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives: - // fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality). + // Bundled bootstrap default. The shared live catalog may remove it or offer newer models. // internal so the model catalog references it directly (single source of truth, drift-guarded). internal const string DefaultModel = "fal-ai/flux-2"; @@ -34,15 +33,22 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); - var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 }; + ModelEntry entry = req.CatalogEntry ?? AssetGenModelCatalog.Find(model); + var body = new JObject(); + if (entry == null || entry.PromptField != null) body[entry?.PromptField ?? "prompt"] = req.Prompt ?? string.Empty; + if (entry == null || (image ? entry.EditSupportsNumImages : entry.SupportsNumImages)) body["num_images"] = 1; + string outputFormat = image ? entry?.EditOutputFormat : entry?.OutputFormat; + if (outputFormat != null) body["output_format"] = outputFormat; string url; if (image) { // image→image / editing lives on the model's /edit endpoint and takes an image_urls // array; each entry accepts a hosted URL or an inline base64 data URI (local image_path). - url = QueueBase + model + "/edit"; + if (entry?.FromRefresh == true && string.IsNullOrEmpty(entry.EditModelId)) + throw new Exception($"Model '{model}' has no verified image editing endpoint."); + url = QueueBase + (entry?.EditModelId ?? model + "/edit"); string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); - body["image_urls"] = new JArray(imageRef); + body[entry?.ImageInputField ?? "image_urls"] = entry?.ImageInputIsArray != false ? (JToken)new JArray(imageRef) : (JToken)imageRef; } else { @@ -51,9 +57,19 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT // Forward explicit output dimensions for text→image only; fal's image_size accepts a // {width,height} object. (/edit derives size from the source image and may reject it. // FLUX has no transparency param — transparent backgrounds aren't a generation-time option.) + if (!image && req.Width > 0 && req.Height > 0 && entry?.SupportsImageSize == false) + throw new Exception($"Model '{model}' does not support width/height image_size parameters."); if (!image && req.Width > 0 && req.Height > 0) body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height }; + return await SubmitQueueAsync(body, url.Substring(QueueBase.Length), apiKey, http, ct); + } + + internal static async Task SubmitQueueAsync(JObject body, string model, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (!FalModelSchema.SafeId(model)) throw new InvalidOperationException("Invalid fal model ID."); + string url = QueueBase + model; + ProviderHttp.RequireHost(url, QueueHost, apiKey, "fal submit"); var spec = new HttpRequestSpec @@ -77,7 +93,7 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); // Queue request URLs are namespaced by owner/app without the action sub-path, // so build from the base model id (not `url`, which may end in /edit). - responseUrl = QueueBase + model + "/requests/" + requestId; + responseUrl = QueueBase + string.Join("/", model.Split('/'), 0, 2) + "/requests/" + Uri.EscapeDataString(requestId); } // The response_url is provider-controlled; refuse to later attach the key to any host // other than the fal queue. @@ -85,7 +101,18 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT return responseUrl; } - public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + => PollQueueAsync(providerJobId, apiKey, http, ct, json => + { + var file = json["images"]?[0] ?? json["image"]; + return new ProviderPollResult + { + DownloadUrl = ExtractImageUrl(json), + ResultExt = ImageResultFormat.FromMetadata((string)file?["content_type"], (string)file?["url"]), + }; + }); + + internal static async Task PollQueueAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct, Func extract) { if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); string responseUrl = providerJobId; @@ -131,12 +158,13 @@ public async Task PollAsync(string providerJobId, string api HttpResult resultRes = await http.SendAsync(resultSpec, ct); JObject resultJson = ParseOk(resultRes, apiKey, "result"); + result = extract(resultJson); + result.State = ProviderPollState.Succeeded; result.Progress = 1f; - result.DownloadUrl = ExtractImageUrl(resultJson); if (string.IsNullOrEmpty(result.DownloadUrl)) { result.State = ProviderPollState.Failed; - result.Error = "fal completed but no image URL was present in the result."; + result.Error = "fal completed but no result URL was present in the result."; } return result; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs index dbf682ca08..39d3de5fe8 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs @@ -11,8 +11,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// - /// fal.ai audio provider via the queue API. One adapter fronts every v1 audio model - /// (stable-audio-25, cassetteai/*, lyria2); the model id in + /// fal.ai audio provider via the queue API. The captured catalog profile describes each + /// compatible model's prompt and duration fields; /// selects the endpoint. Submits to queue.fal.run/{model} (auth header /// "Authorization: Key <key>"), polls status, then returns the result audio URL for the job /// manager to download. Reuses the single existing "fal" secure key. @@ -69,17 +69,17 @@ public async Task SubmitAsync(AudioGenRequest req, string apiKey, IHttpT // prompt-only. private static JObject BuildBody(string model, AudioGenRequest req) { - var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty }; - - ModelEntry entry = AssetGenModelCatalog.Find(model); + ModelEntry entry = req.CatalogEntry ?? AssetGenModelCatalog.Find(model); + var body = new JObject { [entry?.PromptField ?? "prompt"] = req.Prompt ?? string.Empty }; if (entry != null && !string.IsNullOrEmpty(entry.DurationField)) { float dur = req.Duration > 0f ? req.Duration : entry.DefaultDurationSeconds; - float floor = Math.Max(1f, entry.MinDurationSeconds); + float floor = entry.FromRefresh ? entry.MinDurationSeconds : Math.Max(1f, entry.MinDurationSeconds); dur = Math.Min(Math.Max(dur, floor), entry.MaxDurationSeconds); - // Floor (not round) so we never exceed the requested duration, then enforce >= 1. - int seconds = Math.Max(1, (int)Math.Floor(dur)); - body[entry.DurationField] = seconds; + float units = dur * entry.DurationScale; + body[entry.DurationField] = entry.DurationIsInteger + ? (JToken)(long)Math.Max(Math.Ceiling(floor * entry.DurationScale), Math.Floor(units)) + : (JToken)units; } return body; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs new file mode 100644 index 0000000000..d160597158 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + public sealed class FalModelAdapter : IModelProviderAdapter + { + internal const string DefaultModel = "fal-ai/hunyuan3d-v3/text-to-3d"; + private ModelEntry profile; + public string Id => "fal"; + + public Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + profile = req.CatalogEntry; + if (profile == null || profile.VerifiedAt == null) throw new InvalidOperationException("Choose a verified fal 3D model."); + if (!string.Equals(req.Format ?? "glb", "glb", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("fal 3D generation currently supports GLB output. Choose format='glb'."); + if (!string.IsNullOrEmpty(req.Tier)) throw new InvalidOperationException("This fal adapter uses the endpoint's default tier."); + if (!req.Texture && profile.TextureField == null) + throw new InvalidOperationException("This model does not expose a texture toggle. Choose a model that supports it."); + var body = new JObject(); + if (profile.PromptField != null) body[profile.PromptField] = req.Prompt ?? ""; + if (profile.TextureField != null) body[profile.TextureField] = req.Texture; + if (req.Mode == "image") + { + string image = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); + body[profile.ImageInputField] = profile.ImageInputIsArray ? (JToken)new JArray(image) : image; + } + return FalAdapter.SubmitQueueAsync(body, profile.Id, apiKey, http, ct); + } + + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + => FalAdapter.PollQueueAsync(providerJobId, apiKey, http, ct, json => new ProviderPollResult + { + DownloadUrl = (string)json.SelectToken(profile.ModelOutputField + ".url"), ResultExt = "glb", + }); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta new file mode 100644 index 0000000000..0feda610ff --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalModelAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0efe148146db4e159bab4428a05b9093 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs index 3fb6fc2d0e..b7337bbe23 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs @@ -10,8 +10,9 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// - /// OpenRouter image provider via the (synchronous) chat-completions endpoint with an - /// image-capable multimodal model. The image is returned inline (base64 data URL), so the + /// OpenRouter's synchronous Image API with verified per-endpoint capabilities. + /// Legacy direct callers without a catalog profile retain the chat response contract. The + /// image is returned inline, so the /// work happens in and returns it immediately. /// One adapter instance handles a single job (the job manager captures it for submit+poll). /// @@ -26,11 +27,13 @@ public sealed class OpenRouterAdapter : IImageProviderAdapter private byte[] _inlineData; private string _downloadUrl; private string _error; + private string _resultExt; public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) { if (req == null) throw new ArgumentNullException(nameof(req)); if (http == null) throw new ArgumentNullException(nameof(http)); + _inlineData = null; _downloadUrl = null; _error = null; _resultExt = null; string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; @@ -59,11 +62,28 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT ["content"] = content }) }; + bool imageApi = req.CatalogEntry?.RouterProviderTag != null; + if (imageApi) + { + var profile = req.CatalogEntry; + body = new JObject + { + ["model"] = model, ["prompt"] = req.Prompt ?? "", + ["provider"] = new JObject { ["only"] = new JArray(profile.RouterProviderTag), ["allow_fallbacks"] = false }, + }; + if (image) body["input_references"] = new JArray(new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } }); + if (profile.OutputFormat != null) body["output_format"] = profile.OutputFormat; + if (req.Width > 0 && req.Height > 0) + { + if (profile.RouterParameters?["size"] == null) throw new InvalidOperationException("This OpenRouter endpoint does not accept explicit pixel dimensions."); + body["size"] = req.Width + "x" + req.Height; + } + } var spec = new HttpRequestSpec { Method = "POST", - Url = Endpoint, + Url = imageApi ? "https://openrouter.ai/api/v1/images" : Endpoint, ContentType = "application/json", Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) }; @@ -71,6 +91,16 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT HttpResult res = await http.SendAsync(spec, ct); JObject json = ParseOk(res, apiKey); + if (imageApi) + { + var file = json["data"]?[0]; + _resultExt = ImageResultFormat.FromMetadata((string)file?["media_type"]); + try { _inlineData = Convert.FromBase64String((string)file?["b64_json"] ?? ""); } + catch { _error = "OpenRouter returned invalid image bytes."; } + if (_inlineData?.Length == 0) _error = "OpenRouter returned no image."; + if (_resultExt == "svg" || _resultExt == "webp") _error = "This Unity importer requires PNG or JPEG output."; + return "ready"; + } string url = ExtractImageUrl(json); if (string.IsNullOrEmpty(url)) @@ -85,10 +115,12 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; } try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); } catch { _error = "OpenRouter image was not valid base64."; } + _resultExt = ImageResultFormat.FromMetadata(url.Substring(5, Math.Max(0, url.IndexOf(';') - 5))); } else { _downloadUrl = url; + _resultExt = ImageResultFormat.FromMetadata(null, url); } return "ready"; } @@ -106,6 +138,7 @@ public Task PollAsync(string providerJobId, string apiKey, I result.State = ProviderPollState.Succeeded; result.InlineData = _inlineData; result.DownloadUrl = _downloadUrl; + result.ResultExt = _resultExt; } return Task.FromResult(result); } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs index 1e8d0f9e14..ed53f477d1 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs @@ -31,6 +31,7 @@ public sealed class ProviderPollResult /// Request to generate a 3D model. Shared by every model provider adapter. public sealed class ModelGenRequest { + internal ModelEntry CatalogEntry; public string Provider; public string Mode; // text | image public string Prompt; @@ -48,6 +49,7 @@ public sealed class ModelGenRequest /// Request to generate a 2D image. Shared by every image provider adapter. public sealed class ImageGenRequest { + internal ModelEntry CatalogEntry; public string Provider; public string Mode; // text | image public string Prompt; @@ -69,6 +71,7 @@ public sealed class ImageGenRequest /// public sealed class AudioGenRequest { + internal ModelEntry CatalogEntry; public string Provider; // "fal" for v1 public string Model; // fal model id, e.g. fal-ai/stable-audio-25/text-to-audio public string Prompt; diff --git a/MCPForUnity/Editor/Services/Blender.meta b/MCPForUnity/Editor/Services/Blender.meta new file mode 100644 index 0000000000..c2029bf052 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5171d93ea027470b96c8930306ca0c60 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs new file mode 100644 index 0000000000..18caa245c5 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.Blender +{ + /// Where the BlenderMCP addon socket listens. Captured on the main thread so I/O can run elsewhere. + public readonly struct BlenderEndpoint + { + public string Host { get; } + public int Port { get; } + + public BlenderEndpoint(string host, int port) + { + Host = host; + Port = port; + } + + /// Formats the endpoint as host:port. + public override string ToString() => $"{Host}:{Port}"; + } + + /// + /// Minimal client for the BlenderMCP addon socket. Protocol: one JSON object + /// {"type": ..., "params": {...}} per request and one JSON object + /// {"status": "success"|"error", "result"|"message": ...} back, with no length framing — + /// the addon parses whenever the accumulated bytes form valid JSON, so this does the same. + /// A fresh connection is used per command so it never interleaves with the AI client's + /// own BlenderMCP server talking to the same addon. The synchronous methods block and touch + /// no Unity API, so callers run them through the *Async wrappers off the editor thread. + /// + public static class BlenderSocketClient + { + private const int ConnectTimeoutSeconds = 3; + + /// Sends one command and blocks until the addon answers or the timeout elapses. + public static JToken Send(BlenderEndpoint endpoint, string type, JObject @params = null, int timeoutSeconds = 60) + { + var request = new JObject { ["type"] = type, ["params"] = @params ?? new JObject() }; + byte[] payload = Encoding.UTF8.GetBytes(request.ToString(Formatting.None)); + + using var client = new TcpClient(); + IAsyncResult connect = client.BeginConnect(endpoint.Host, endpoint.Port, null, null); + if (!connect.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(ConnectTimeoutSeconds)) || !client.Connected) + { + throw new BlenderUnavailableException( + $"Blender addon not reachable at {endpoint}. Start Blender and press " + + "'Connect to MCP server' in the BlenderMCP sidebar (N panel)."); + } + client.EndConnect(connect); + + using NetworkStream stream = client.GetStream(); + stream.ReadTimeout = Math.Max(1, timeoutSeconds) * 1000; + stream.Write(payload, 0, payload.Length); + stream.Flush(); + + var buffer = new MemoryStream(); + var chunk = new byte[65536]; + DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (true) + { + int n; + try + { + n = stream.Read(chunk, 0, chunk.Length); + } + catch (IOException e) + { + throw new TimeoutException( + $"Timed out after {timeoutSeconds}s waiting for Blender to answer '{type}'.", e); + } + + if (n <= 0) break; + buffer.Write(chunk, 0, n); + + if (TryParseResponse(buffer.GetBuffer(), (int)buffer.Length, out JObject parsed)) return Unwrap(parsed, type); + if (DateTime.UtcNow > deadline) + throw new TimeoutException($"Timed out after {timeoutSeconds}s waiting for Blender to answer '{type}'."); + } + + if (TryParseResponse(buffer.GetBuffer(), (int)buffer.Length, out JObject final)) return Unwrap(final, type); + throw new IOException($"Blender closed the connection before a complete response to '{type}' arrived."); + } + + /// Runs on the thread pool so the editor stays responsive. + public static Task SendAsync(BlenderEndpoint endpoint, string type, JObject @params = null, int timeoutSeconds = 60) + { + return Task.Run(() => Send(endpoint, type, @params, timeoutSeconds)); + } + + /// Runs Python inside Blender (blocking) and returns its captured stdout. + public static string RunPython(BlenderEndpoint endpoint, string code, int timeoutSeconds = 120) + { + JToken result = Send(endpoint, "execute_code", new JObject { ["code"] = code }, timeoutSeconds); + return result?["result"]?.ToString() ?? string.Empty; + } + + /// Runs on the thread pool. + public static Task RunPythonAsync(BlenderEndpoint endpoint, string code, int timeoutSeconds = 120) + { + return Task.Run(() => RunPython(endpoint, code, timeoutSeconds)); + } + + /// Probes the addon with get_scene_info; returns whether it answered and the error text if not. + public static (bool Ok, string Error) Probe(BlenderEndpoint endpoint, int timeoutSeconds = 10) + { + try + { + Send(endpoint, "get_scene_info", null, timeoutSeconds); + return (true, null); + } + catch (Exception e) + { + return (false, e.Message); + } + } + + /// Runs on the thread pool. + public static Task<(bool Ok, string Error)> ProbeAsync(BlenderEndpoint endpoint, int timeoutSeconds = 10) + { + return Task.Run(() => Probe(endpoint, timeoutSeconds)); + } + + /// True once the bytes received so far form one complete JSON object. + internal static bool TryParseResponse(byte[] buffer, int length, out JObject obj) + { + try + { + obj = JObject.Parse(Encoding.UTF8.GetString(buffer, 0, length)); + return true; + } + catch + { + obj = null; + return false; + } + } + + /// + /// Returns the addon's "result" payload for a success response, throws + /// for an error response, and rejects anything else + /// so a malformed reply never surfaces as a successful command with a null payload. + /// + internal static JToken Unwrap(JObject response, string type) + { + string status = (string)response?["status"]; + if (string.Equals(status, "success", StringComparison.OrdinalIgnoreCase)) + return response["result"]; + if (string.Equals(status, "error", StringComparison.OrdinalIgnoreCase)) + throw new BlenderCommandException($"Blender '{type}' failed: {(string)response["message"] ?? "unknown error"}"); + throw new InvalidDataException($"Blender returned an invalid status '{status ?? "(none)"}' for '{type}'."); + } + } + + /// The addon socket could not be reached (Blender closed or the addon not connected). + public class BlenderUnavailableException : Exception + { + public BlenderUnavailableException(string message) : base(message) { } + } + + /// The addon accepted the command but reported an error while running it. + public class BlenderCommandException : Exception + { + public BlenderCommandException(string message) : base(message) { } + } +} diff --git a/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta new file mode 100644 index 0000000000..73c1144e02 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6c83ed8aaae34a829fc59df7c1a53948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs index 94f6a397ab..a33243fd8e 100644 --- a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -45,7 +45,14 @@ public static EditorConfigurationCache Instance /// public event Action OnConfigurationChanged; + // A headless CI/harness editor (McpCiBoot) must run stdio no matter what the machine-wide + // EditorPrefs say: on Windows those prefs are per-user, so a developer who uses HTTP would + // otherwise have HttpAutoStartHandler stop the stdio bridge the harness is talking to. + // SessionState lives exactly as long as that editor process and survives its domain reloads. + internal const string SessionKeyForceStdio = "MCPForUnity.ForceStdioForSession"; + // Cached values - most frequently read + private bool _forceStdioForSession; private bool _useHttpTransport; private bool _debugLogs; private bool _devModeForceServerRefresh; @@ -59,9 +66,9 @@ public static EditorConfigurationCache Instance /// /// Whether to use HTTP transport (true) or Stdio transport (false). - /// Default: true + /// Default: true. Always false while is in effect. /// - public bool UseHttpTransport => _useHttpTransport; + public bool UseHttpTransport => !_forceStdioForSession && _useHttpTransport; /// /// Whether debug logging is enabled. @@ -128,6 +135,7 @@ private EditorConfigurationCache() /// public void Refresh() { + _forceStdioForSession = SessionState.GetBool(SessionKeyForceStdio, false); _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); @@ -140,6 +148,31 @@ public void Refresh() _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); } + /// + /// Force stdio transport for the rest of this editor session without touching EditorPrefs. + /// Every UseHttpTransport consumer (auto-start, reload handlers, BridgeControlService, + /// client configurators) sees stdio until or editor exit. + /// + public void PinStdioForSession() + { + SessionState.SetBool(SessionKeyForceStdio, true); + if (!_forceStdioForSession) + { + _forceStdioForSession = true; + OnConfigurationChanged?.Invoke(nameof(UseHttpTransport)); + } + } + + public void UnpinStdioForSession() + { + SessionState.EraseBool(SessionKeyForceStdio); + if (_forceStdioForSession) + { + _forceStdioForSession = false; + OnConfigurationChanged?.Invoke(nameof(UseHttpTransport)); + } + } + /// /// Set UseHttpTransport and update cache + EditorPrefs atomically. /// diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs index d02b265282..1efb8b9a6f 100644 --- a/MCPForUnity/Editor/Services/EditorStateCache.cs +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Reflection; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json; @@ -22,8 +23,24 @@ internal static class EditorStateCache private static long _observedUnixMs; private static bool _lastIsCompiling; - private static long? _lastCompileStartedUnixMs; - private static long? _lastCompileFinishedUnixMs; + + // Compile edges live in SessionState, recorded from the CompilationPipeline + // events, rather than in statics sampled off the update tick. Two reasons, + // both load-bearing: + // + // - A successful compile ends in a domain reload that wipes every static in + // this class, including the "was compiling" flag the falling edge was + // derived from. The finish of the very compile a client is waiting on was + // therefore unobservable: both timestamps read null afterwards, so nothing + // downstream could tell "finished" from "never started" (issue #814). + // - The events fire at the true edges. Sampling quantised them to the 1s + // update throttle and dropped any compile shorter than one tick entirely. + // + // SessionState survives domain reloads and dies with the editor session, + // which is exactly the lifetime these values describe. + private const string CompileStartedKey = "MCPForUnity.EditorState.CompileStartedUnixMs"; + private const string CompileFinishedKey = "MCPForUnity.EditorState.CompileFinishedUnixMs"; + private const string CompileCountKey = "MCPForUnity.EditorState.CompileCount"; private static bool _domainReloadPending; private static long? _domainReloadBeforeUnixMs; @@ -262,8 +279,24 @@ static EditorStateCache() // Tracks whether an assembly compilation is actually running, for // GetActualIsCompiling. Statics reset on domain reload and this // [InitializeOnLoad] ctor re-subscribes, so the flag is per-domain. - UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ => _pipelineCompilationRunning = true; - UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => _pipelineCompilationRunning = false; + // The timestamps beside it are not per-domain — see the SessionState + // note on CompileStartedKey. + UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ => + { + _pipelineCompilationRunning = true; + SetSessionUnixMs(CompileStartedKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + SessionState.SetInt(CompileCountKey, SessionState.GetInt(CompileCountKey, 0) + 1); + ForceUpdate("compilation_started"); + }; + UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => + { + _pipelineCompilationRunning = false; + // Fires before the domain reload, which is what makes the finish + // observable at all: the write lands while this domain is alive and + // is read back by the next one. + SetSessionUnixMs(CompileFinishedKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + ForceUpdate("compilation_finished"); + }; AssemblyReloadEvents.beforeAssemblyReload += () => { @@ -378,14 +411,6 @@ private static JObject BuildSnapshot(string reason) _observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); bool isCompiling = GetActualIsCompiling(); - if (isCompiling && !_lastIsCompiling) - { - _lastCompileStartedUnixMs = _observedUnixMs; - } - else if (!isCompiling && _lastIsCompiling) - { - _lastCompileFinishedUnixMs = _observedUnixMs; - } _lastIsCompiling = isCompiling; var scene = EditorSceneManager.GetActiveScene(); @@ -458,8 +483,8 @@ private static JObject BuildSnapshot(string reason) { IsCompiling = isCompiling, IsDomainReloadPending = _domainReloadPending, - LastCompileStartedUnixMs = _lastCompileStartedUnixMs, - LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs, + LastCompileStartedUnixMs = GetSessionUnixMs(CompileStartedKey), + LastCompileFinishedUnixMs = GetSessionUnixMs(CompileFinishedKey), LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs, LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs }, @@ -535,6 +560,27 @@ public static JObject GetSnapshot() } } + /// + /// Compilations begun this editor session, surviving domain reloads. Callers + /// that trigger a compile snapshot this first, then wait for it to move — the + /// only signal that separates "a compile ran" from "one never started", which + /// reads as idle either way. + /// + internal static int CompileCount => SessionState.GetInt(CompileCountKey, 0); + + internal static long? GetSessionUnixMs(string key) + { + string raw = SessionState.GetString(key, string.Empty); + return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value) + ? value + : (long?)null; + } + + // SessionState has no long overload, so these round-trip through an + // invariant string rather than losing precision through int or float. + internal static void SetSessionUnixMs(string key, long value) + => SessionState.SetString(key, value.ToString(CultureInfo.InvariantCulture)); + // Set/cleared by the CompilationPipeline.compilationStarted/Finished events // subscribed in the static ctor. NOTE: CompilationPipeline.isCompiling does not // exist on the supported Unity range (verified by reflection probe on 2021.3 and diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index bdf626036d..b20b2f7cf0 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -46,6 +46,7 @@ internal sealed class TestJob /// /// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs. /// + [InitializeOnLoad] internal static class TestJobManager { // Keep this small to avoid ballooning payloads during polling. @@ -69,6 +70,42 @@ static TestJobManager() { // Restore after domain reloads (e.g., compilation while a job is running). TryRestoreFromSessionState(); + AssemblyReloadEvents.beforeAssemblyReload += BeforeAssemblyReload; + RestoreRunningJobCallbacks(); + } + + private static void BeforeAssemblyReload() + { + // Progress callbacks are normally throttled. Flush the last update before the + // managed domain (and the original RunTestsAsync task) is discarded. + PersistToSessionState(force: true); + } + + private static void RestoreRunningJobCallbacks() + { + TestJob job; + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || + !Jobs.TryGetValue(_currentJobId, out job) || job.Status != TestJobStatus.Running) + { + return; + } + } + + try + { + // Polling a restored job never otherwise touches the lazy test service. + // Re-register callbacks now, before the Test Runner resumes after reload. + if (MCPServiceLocator.Tests is TestRunnerService service) + { + service.ResumeJobAfterReload(job.JobId, job.Mode); + } + } + catch (Exception ex) + { + McpLog.Warn($"[TestJobManager] Failed to restore test callbacks: {ex.Message}"); + } } public static string CurrentJobId @@ -381,7 +418,33 @@ public static void FinalizeCurrentJobFromRunFinished(TestRunResult resultPayload : TestJobStatus.Succeeded; job.Error = null; job.Result = resultPayload; + if (resultPayload != null) + { + job.TotalTests = resultPayload.Total; + job.CompletedTests = resultPayload.Total; + } job.CurrentTestFullName = null; + job.CurrentTestStartedUnixMs = null; + _currentJobId = null; + } + PersistToSessionState(force: true); + } + + internal static void FinalizeCurrentJobFromRunError(string message) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job)) + { + return; + } + job.Status = TestJobStatus.Failed; + job.Error = message; + job.LastUpdateUnixMs = now; + job.FinishedUnixMs = now; + job.CurrentTestFullName = null; + job.CurrentTestStartedUnixMs = null; _currentJobId = null; } PersistToSessionState(force: true); @@ -539,7 +602,7 @@ internal static object ToSerializable(TestJob job, bool includeDetails, bool inc } object resultPayload = null; - if (job.Status == TestJobStatus.Succeeded && job.Result != null) + if (job.Status != TestJobStatus.Running && job.Result != null) { resultPayload = job.Result.ToSerializable(job.Mode, includeDetails, includeFailedTests); } @@ -562,6 +625,7 @@ internal static object ToSerializable(TestJob job, bool includeDetails, bool inc last_finished_unix_ms = job.LastFinishedUnixMs, stuck_suspected = IsStuck(job), editor_is_focused = InternalEditorUtility.isApplicationActive, + run_in_background = UnityEngine.Application.runInBackground, blocked_reason = GetBlockedReason(job), failures_so_far = BuildFailuresPayload(job.FailuresSoFar), failures_capped = (job.FailuresSoFar != null && job.FailuresSoFar.Count >= FailureCap) @@ -685,4 +749,3 @@ private static void FinalizeFromTask(string jobId, Task task) } } } - diff --git a/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs b/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs index ddcfe87028..b1a1dbfefa 100644 --- a/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs +++ b/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs @@ -130,7 +130,7 @@ private static void ForceEditorToApplyInteractionPrefs() } } - private sealed class TestCallbacks : ICallbacks + private sealed class TestCallbacks : IErrorCallbacks { public void RunStarted(ITestAdaptor testsToRun) { @@ -143,6 +143,12 @@ public void RunFinished(ITestResultAdaptor result) RestoreThrottling(); } + public void OnError(string message) + { + // Build/prebuild failures may terminate before RunFinished is delivered. + RestoreThrottling(); + } + public void TestStarted(ITestAdaptor test) { } public void TestFinished(ITestResultAdaptor result) { } } diff --git a/MCPForUnity/Editor/Services/TestRunnerService.cs b/MCPForUnity/Editor/Services/TestRunnerService.cs index 13c6f0056d..f16a389d95 100644 --- a/MCPForUnity/Editor/Services/TestRunnerService.cs +++ b/MCPForUnity/Editor/Services/TestRunnerService.cs @@ -34,10 +34,15 @@ internal static class PlayModeOptionsGuard private static readonly string MarkerPath = Path.Combine("Library", "MCPPlayModeOptionsBackup.txt"); static PlayModeOptionsGuard() + { + RestoreIfIdle(); + } + + internal static void RestoreIfIdle() { // After domain reload or editor restart: if a restore is pending and no test run // is active, restore now. TryLoad checks SessionState first, then the marker file. - if (TryLoad(out _, out _) && !TestRunStatus.IsRunning) + if (TryLoad(out _, out _) && !TestRunStatus.IsRunning && !TestJobManager.HasRunningJob) { Restore(); } @@ -143,7 +148,7 @@ private static bool TryLoad(out bool originalEnabled, out EnterPlayModeOptions o /// Concrete implementation of . /// Coordinates Unity Test Runner operations and produces structured results. /// - internal sealed class TestRunnerService : ITestRunnerService, ICallbacks, IDisposable + internal sealed class TestRunnerService : ITestRunnerService, IErrorCallbacks, IDisposable { private static readonly TestMode[] AllModes = { TestMode.EditMode, TestMode.PlayMode }; @@ -151,13 +156,33 @@ internal sealed class TestRunnerService : ITestRunnerService, ICallbacks, IDispo private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); private readonly List _leafResults = new List(); private TaskCompletionSource _runCompletionSource; + private string _trackedJobId; public TestRunnerService() { _testRunnerApi = ScriptableObject.CreateInstance(); + _testRunnerApi.hideFlags = HideFlags.HideAndDontSave; _testRunnerApi.RegisterCallbacks(this); } + internal void ResumeJobAfterReload(string jobId, string mode) + { + if (_runCompletionSource != null || _trackedJobId != null || + string.IsNullOrEmpty(jobId) || TestJobManager.CurrentJobId != jobId) + { + return; + } + + _trackedJobId = jobId; + if (Enum.TryParse(mode, out var testMode)) + { + TestRunStatus.MarkStarted(testMode); + } + } + + private bool IsTrackingCurrentJob => + !string.IsNullOrEmpty(_trackedJobId) && TestJobManager.CurrentJobId == _trackedJobId; + public async Task>> GetTestsAsync(TestMode? mode) { await _operationLock.WaitAsync().ConfigureAwait(true); @@ -187,6 +212,13 @@ public async Task>> GetTestsAsync(TestM public async Task RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null) { + // The pre-reload Task no longer exists, but its Unity run may still be active. + // Clearing a job must not let a second run consume the first run's callbacks. + if (_trackedJobId != null && _runCompletionSource == null) + { + throw new InvalidOperationException("A recovered Unity test run is still in progress."); + } + await _operationLock.WaitAsync().ConfigureAwait(true); Task runTask; bool adjustedPlayModeOptions = false; @@ -219,6 +251,7 @@ public async Task RunTestsAsync(TestMode mode, TestFilterOptions } _leafResults.Clear(); + _trackedJobId = TestJobManager.CurrentJobId; _runCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Mark running immediately so readiness snapshots reflect the busy state even before callbacks fire. TestRunStatus.MarkStarted(mode); @@ -252,6 +285,8 @@ public async Task RunTestsAsync(TestMode mode, TestFilterOptions } catch { + _trackedJobId = null; + _runCompletionSource = null; // Ensure the status is cleared if we failed to start the run. TestRunStatus.MarkFinished(); if (adjustedPlayModeOptions) @@ -302,6 +337,10 @@ public void Dispose() public void RunStarted(ITestAdaptor testsToRun) { _leafResults.Clear(); + if (!IsTrackingCurrentJob) + { + return; + } try { // Best-effort progress info for async polling (avoid heavy payloads). @@ -325,31 +364,64 @@ public void RunFinished(ITestResultAdaptor result) // is recreated and _runCompletionSource is lost, but TestJobManager state persists via // SessionState and the Test Runner still delivers the RunFinished callback. var payload = TestRunResult.Create(result, _leafResults); + CompleteRun(payload, null); + } - // Clean up state regardless of _runCompletionSource - these methods safely handle - // the case where no MCP job exists (e.g., manual test runs via Unity UI). - TestRunStatus.MarkFinished(); - TestJobManager.OnRunFinished(); - TestJobManager.FinalizeCurrentJobFromRunFinished(payload); + public void OnError(string message) + { + // Build/prebuild failures use IErrorCallbacks instead of RunFinished. + CompleteRun(null, new InvalidOperationException(message ?? "Unity test run failed.")); + } + + private void CompleteRun(TestRunResult payload, Exception error) + { + + // A late callback from a cleared job must not finish a newer job or restore + // settings belonging to it. Callbacks do not contain an MCP job identifier. + bool ownsCurrentJob = IsTrackingCurrentJob; + bool canCleanUp = ownsCurrentJob || !TestJobManager.HasRunningJob; + if (canCleanUp) + { + TestRunStatus.MarkFinished(); + } + if (ownsCurrentJob) + { + if (error == null) + { + TestJobManager.OnRunFinished(); + TestJobManager.FinalizeCurrentJobFromRunFinished(payload); + } + else + { + TestJobManager.FinalizeCurrentJobFromRunError(error.Message); + } + } // If a domain reload destroyed the original RunTestsAsync caller, the finally block // that would normally restore EditorSettings never ran. Restore from SessionState. - if (_runCompletionSource == null && PlayModeOptionsGuard.IsPending) + if (canCleanUp && _trackedJobId != null && _runCompletionSource == null && PlayModeOptionsGuard.IsPending) { PlayModeOptionsGuard.Restore(); } // Report result to awaiting caller if we have a completion source. // The caller's finally block handles restoration in this case. - if (_runCompletionSource != null) + var completion = _runCompletionSource; + _runCompletionSource = null; + _trackedJobId = null; + if (completion != null) { - _runCompletionSource.TrySetResult(payload); - _runCompletionSource = null; + if (error == null) completion.TrySetResult(payload); + else completion.TrySetException(error); } } public void TestStarted(ITestAdaptor test) { + if (!IsTrackingCurrentJob) + { + return; + } try { // Prefer FullName for uniqueness; fall back to Name. @@ -373,7 +445,7 @@ public void TestFinished(ITestResultAdaptor result) return; } - if (!result.HasChildren) + if (!result.HasChildren && result.Test?.IsSuite != true) { _leafResults.Add(result); try @@ -402,7 +474,10 @@ public void TestFinished(ITestResultAdaptor result) // ignore adaptor quirks } - TestJobManager.OnLeafTestFinished(fullName, isFailure, message); + if (IsTrackingCurrentJob) + { + TestJobManager.OnLeafTestFinished(fullName, isFailure, message); + } } catch { @@ -638,7 +713,16 @@ public object ToSerializable(string mode, bool includeDetails = false, bool incl internal static TestRunResult Create(ITestResultAdaptor summary, IReadOnlyList tests) { - var materializedTests = tests.Select(TestRunTestResult.FromAdaptor).ToList(); + // RunFinished includes the complete result tree, including tests completed + // before a domain reload erased the service's per-test callback list. + var resultLeaves = new List(); + if (summary != null && summary.HasChildren) + { + CollectResultLeaves(summary, resultLeaves); + } + var materializedTests = (resultLeaves.Count > 0 ? resultLeaves : tests) + .Where(t => t != null && t.Test?.IsSuite != true) + .Select(TestRunTestResult.FromAdaptor).ToList(); int passed = summary?.PassCount ?? materializedTests.Count(t => string.Equals(t.State, "Passed", StringComparison.OrdinalIgnoreCase)); @@ -662,6 +746,26 @@ internal static TestRunResult Create(ITestResultAdaptor summary, IReadOnlyList leaves) + { + if (result == null) + { + return; + } + if (!result.HasChildren) + { + if (result.Test?.IsSuite != true) leaves.Add(result); + return; + } + if (result.Children != null) + { + foreach (var child in result.Children) + { + CollectResultLeaves(child, leaves); + } + } + } } public sealed class TestRunSummary diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs index e2ff512547..cbda375abd 100644 --- a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -23,30 +23,19 @@ public List DiscoverAllTools() _cachedTools = new Dictionary(); - // Primary scan via TypeCache (fast, but can miss project assemblies in some domain-reload states) - var toolTypes = TypeCache.GetTypesWithAttribute(); - - // Fallback scan via AppDomain (slower but exhaustive; mirrors CommandRegistry behaviour) - var appDomainTypes = AppDomain.CurrentDomain.GetAssemblies() - .Where(a => !a.IsDynamic) - .SelectMany(a => - { - try { return a.GetTypes(); } - catch (Exception ex) - { - McpLog.Warn($"Failed to reflect types from assembly {a.FullName}: {ex.Message}"); - return new Type[0]; - } - }) - .Where(t => t.GetCustomAttribute() != null); - - // Merge both scans, deduplicating by type - var allToolTypes = toolTypes - .Concat(appDomainTypes) - .Distinct() - .ToList(); - - foreach (var type in allToolTypes) + // TypeCache is Unity's precomputed attribute index, rebuilt once per domain reload. + // This used to query it and then ALSO materialise every type in every loaded assembly + // and call GetCustomAttribute on each one, before merging the two sets — so the fast + // path never actually saved anything (issue #1336). The result is cached, so the walk + // ran once per domain reload, and again on each tool toggle in the editor window, + // which invalidates the cache. A reload therefore paid for two full assembly walks: + // this one and CommandRegistry.AutoDiscoverCommands, which #1364 removed. This brings + // the tool registry in line with it, and with ResourceDiscoveryService, which has + // always been TypeCache-only. + // + // Ordered because a duplicate tool name overwrites the previous registration below, + // so registration order decides which one wins, and TypeCache documents no order. + foreach (var type in InRegistrationOrder(TypeCache.GetTypesWithAttribute())) { McpForUnityToolAttribute toolAttr; try @@ -80,6 +69,19 @@ public List DiscoverAllTools() return _cachedTools.Values.ToList(); } + /// + /// Stable registration order for discovered tool types. FullName alone is not a total + /// order: two assemblies can declare the same full type name, and if their tool names + /// also collide the later registration wins, so the assembly identity breaks the tie. + /// Without both keys a collision could resolve differently between domain reloads. + /// + internal static IEnumerable InRegistrationOrder(IEnumerable types) + { + return types + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .ThenBy(type => type.Assembly.FullName, StringComparer.Ordinal); + } + public ToolMetadata GetToolMetadata(string toolName) { if (_cachedTools == null) diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 4c0ff0b87b..32567efa7d 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -27,6 +27,12 @@ class QueuedCommand public TaskCompletionSource Tcs; public bool IsExecuting; public long EnqueuedAtMs; + + /// + /// Connection that queued this command, used to tell a broker resend apart from a + /// genuinely new request. Never dereferenced — identity only. + /// + public object Owner; } [InitializeOnLoad] @@ -262,6 +268,46 @@ private static void EnsureStartedOnEditorIdle() // Routed through EditorStateCache so a deferred domain reload (issue #1276) does not // pin the bridge off: raw EditorApplication.isCompiling stays true for as long as the // reload is held, and this gates bridge startup. + /// + /// Number of commands currently queued, read under the queue lock. Diagnostics only — + /// callers must not reason about individual entries, since the queue mutates from both + /// the listener tasks and the editor update loop. + /// + internal static int QueuedCommandCount + { + get { lock (lockObj) { return commandQueue.Count; } } + } + + /// + /// True when an already-queued command is the same payload arriving from a different + /// connection — the signature of a broker that reconnected and resent. Payload equality + /// alone is not enough: a single connection handles one command at a time, so two + /// identical payloads on the same connection are sequential and genuinely distinct. + /// + internal static bool IsBrokerResend( + string queuedCommandJson, object queuedOwner, string incomingCommandJson, object incomingOwner) + { + if (queuedOwner == null || incomingOwner == null) return false; + if (ReferenceEquals(queuedOwner, incomingOwner)) return false; + return string.Equals(queuedCommandJson, incomingCommandJson, StringComparison.Ordinal); + } + + /// + /// Finds an in-flight command that is resending. + /// Callers must hold . + /// + private static QueuedCommand FindBrokerResendTarget(string commandText, object incomingOwner) + { + foreach (var kvp in commandQueue) + { + if (IsBrokerResend(kvp.Value.CommandJson, kvp.Value.Owner, commandText, incomingOwner)) + { + return kvp.Value; + } + } + return null; + } + private static bool IsCompiling() => EditorStateCache.GetActualIsCompiling(); public static void Start() @@ -600,15 +646,31 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken continue; } + // A command already in flight from a different connection means the + // broker gave up waiting, reconnected and resent it. Running it a + // second time would duplicate side effects (issue #1130), so attach + // to the original instead of queueing a copy. + TaskCompletionSource pending = tcs; lock (lockObj) { - commandQueue[commandId] = new QueuedCommand + QueuedCommand inFlight = FindBrokerResendTarget(commandText, client); + if (inFlight != null) { - CommandJson = commandText, - Tcs = tcs, - IsExecuting = false, - EnqueuedAtMs = _uptime.ElapsedMilliseconds - }; + pending = inFlight.Tcs; + McpLog.Warn("Suppressed duplicate command resent on a new connection; " + + "awaiting the in-flight result instead of running it twice."); + } + else + { + commandQueue[commandId] = new QueuedCommand + { + CommandJson = commandText, + Tcs = tcs, + IsExecuting = false, + EnqueuedAtMs = _uptime.ElapsedMilliseconds, + Owner = client + }; + } } // Force Unity's main loop to iterate even when backgrounded, @@ -620,11 +682,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken try { using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); - var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); - if (completed == tcs.Task) + var completed = await Task.WhenAny(pending.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); + if (completed == pending.Task) { respCts.Cancel(); - response = tcs.Task.Result; + response = pending.Task.Result; Interlocked.Exchange(ref _consecutiveTimeouts, 0); } else diff --git a/MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs b/MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs index 7ca43e38e8..bd8f9a3476 100644 --- a/MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs +++ b/MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs @@ -15,9 +15,9 @@ public static object Play(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); string stateName = @params["stateName"]?.ToString(); if (string.IsNullOrEmpty(stateName)) @@ -28,7 +28,7 @@ public static object Play(JObject @params) Undo.RecordObject(animator, "Play Animation State"); animator.Play(stateName, layer); - return new { success = true, message = $"Playing state '{stateName}' on '{go.name}'" }; + return new { success = true, message = $"Playing state '{stateName}' on {AnimatorResolver.Describe(go, animator)}" }; } public static object Crossfade(JObject @params) @@ -37,9 +37,9 @@ public static object Crossfade(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); string stateName = @params["stateName"]?.ToString(); if (string.IsNullOrEmpty(stateName)) @@ -51,7 +51,7 @@ public static object Crossfade(JObject @params) Undo.RecordObject(animator, "Crossfade Animation State"); animator.CrossFade(stateName, duration, layer); - return new { success = true, message = $"Crossfading to '{stateName}' over {duration}s on '{go.name}'" }; + return new { success = true, message = $"Crossfading to '{stateName}' over {duration}s on {AnimatorResolver.Describe(go, animator)}" }; } public static object SetParameter(JObject @params) @@ -60,9 +60,9 @@ public static object SetParameter(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); string paramName = @params["parameterName"]?.ToString(); if (string.IsNullOrEmpty(paramName)) @@ -103,23 +103,23 @@ public static object SetParameter(JObject @params) case "float": float fVal = valueToken?.ToObject() ?? 0f; animator.SetFloat(paramName, fVal); - return new { success = true, message = $"Set float '{paramName}' = {fVal}" }; + return new { success = true, message = $"Set float '{paramName}' = {fVal}" + AnimatorResolver.ResolvedSuffix(go, animator) }; case "int": case "integer": int iVal = valueToken?.ToObject() ?? 0; animator.SetInteger(paramName, iVal); - return new { success = true, message = $"Set int '{paramName}' = {iVal}" }; + return new { success = true, message = $"Set int '{paramName}' = {iVal}" + AnimatorResolver.ResolvedSuffix(go, animator) }; case "bool": case "boolean": bool bVal = valueToken?.ToObject() ?? false; animator.SetBool(paramName, bVal); - return new { success = true, message = $"Set bool '{paramName}' = {bVal}" }; + return new { success = true, message = $"Set bool '{paramName}' = {bVal}" + AnimatorResolver.ResolvedSuffix(go, animator) }; case "trigger": animator.SetTrigger(paramName); - return new { success = true, message = $"Set trigger '{paramName}'" }; + return new { success = true, message = $"Set trigger '{paramName}'" + AnimatorResolver.ResolvedSuffix(go, animator) }; default: return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" }; @@ -130,7 +130,7 @@ public static object SetParameter(JObject @params) // Edit mode: modify the AnimatorController asset's default parameter values var controller = animator.runtimeAnimatorController as AnimatorController; if (controller == null) - return new { success = false, message = $"No AnimatorController assigned to Animator on '{go.name}'. Cannot set parameter defaults in Edit mode." }; + return new { success = false, message = $"No AnimatorController assigned to the Animator on {AnimatorResolver.Describe(go, animator)}. Cannot set parameter defaults in Edit mode." }; var allParams = controller.parameters; int paramIndex = -1; @@ -156,7 +156,7 @@ public static object SetParameter(JObject @params) controller.parameters = allParams; EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); - return new { success = true, message = $"Set float '{paramName}' = {fVal} (default value, Edit mode)" }; + return new { success = true, message = $"Set float '{paramName}' = {fVal} (default value, Edit mode)" + AnimatorResolver.ResolvedSuffix(go, animator) }; case "int": case "integer": @@ -165,7 +165,7 @@ public static object SetParameter(JObject @params) controller.parameters = allParams; EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); - return new { success = true, message = $"Set int '{paramName}' = {iVal} (default value, Edit mode)" }; + return new { success = true, message = $"Set int '{paramName}' = {iVal} (default value, Edit mode)" + AnimatorResolver.ResolvedSuffix(go, animator) }; case "bool": case "boolean": @@ -174,10 +174,10 @@ public static object SetParameter(JObject @params) controller.parameters = allParams; EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); - return new { success = true, message = $"Set bool '{paramName}' = {bVal} (default value, Edit mode)" }; + return new { success = true, message = $"Set bool '{paramName}' = {bVal} (default value, Edit mode)" + AnimatorResolver.ResolvedSuffix(go, animator) }; case "trigger": - return new { success = true, message = $"Trigger '{paramName}' noted (triggers are runtime-only, no default to set)" }; + return new { success = true, message = $"Trigger '{paramName}' noted (triggers are runtime-only, no default to set)" + AnimatorResolver.ResolvedSuffix(go, animator) }; default: return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" }; @@ -191,16 +191,16 @@ public static object SetSpeed(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); float speed = @params["speed"]?.ToObject() ?? 1f; Undo.RecordObject(animator, "Set Animator Speed"); animator.speed = speed; - return new { success = true, message = $"Set animator speed to {speed} on '{go.name}'" }; + return new { success = true, message = $"Set animator speed to {speed} on {AnimatorResolver.Describe(go, animator)}" }; } public static object SetEnabled(JObject @params) @@ -209,16 +209,16 @@ public static object SetEnabled(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); bool enabled = @params["enabled"]?.ToObject() ?? true; Undo.RecordObject(animator, "Set Animator Enabled"); animator.enabled = enabled; - return new { success = true, message = $"Animator {(enabled ? "enabled" : "disabled")} on '{go.name}'" }; + return new { success = true, message = $"Animator {(enabled ? "enabled" : "disabled")} on {AnimatorResolver.Describe(go, animator)}" }; } } } diff --git a/MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs b/MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs index 722280ae02..718a7d1531 100644 --- a/MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs +++ b/MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs @@ -14,9 +14,9 @@ public static object GetInfo(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); var parameters = new List(); for (int i = 0; i < animator.parameterCount; i++) @@ -73,6 +73,7 @@ public static object GetInfo(JObject @params) data = new { gameObject = go.name, + animatorGameObject = animator.gameObject.name, enabled = animator.enabled, speed = animator.speed, hasController = animator.runtimeAnimatorController != null, @@ -95,9 +96,9 @@ public static object GetParameter(JObject @params) if (go == null) return new { success = false, message = "Target GameObject not found" }; - var animator = go.GetComponent(); + var animator = AnimatorResolver.Find(go, out var animatorCandidates); if (animator == null) - return new { success = false, message = $"No Animator component on '{go.name}'" }; + return AnimatorResolver.NotResolvedError(go, animatorCandidates); string paramName = @params["parameterName"]?.ToString(); if (string.IsNullOrEmpty(paramName)) diff --git a/MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs b/MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs new file mode 100644 index 0000000000..e2b1abd1fc --- /dev/null +++ b/MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs @@ -0,0 +1,82 @@ +using System.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Animation +{ + internal static class AnimatorResolver + { + /// + /// Resolves the Animator that read and control operations should act on: the one on + /// itself, or the single Animator among its descendants. + /// + /// + /// Imported models keep their Animator on the model root, which is normally a child of + /// the GameObject a caller names, so an exact-match lookup fails on the most common rig + /// setup. Inactive descendants are included because a disabled rig is still readable. + /// Operations that ADD an Animator must not use this - they need the exact target. + /// + /// + /// The descendant Animators found when the target carried none. Unity's descendant search + /// is depth-first, so with several rigs under one wrapper it returns the first branch + /// however deep, which is not the nearest rig and not what a caller would predict. More + /// than one candidate therefore resolves to null and is reported, never guessed. + /// + /// The resolved Animator, or null when there is none or the choice is ambiguous. + public static Animator Find(GameObject go, out Animator[] candidates) + { + candidates = System.Array.Empty(); + if (go == null) + return null; + + var own = go.GetComponent(); + if (own != null) + return own; + + // go carries none, so every hit here is a descendant. + candidates = go.GetComponentsInChildren(true); + return candidates.Length == 1 ? candidates[0] : null; + } + + /// + /// The error for a target whose Animator could not be resolved - missing, or ambiguous + /// because several descendants carry one. + /// + public static object NotResolvedError(GameObject go, Animator[] candidates) + { + if (candidates != null && candidates.Length > 1) + { + string names = string.Join(", ", candidates.Select(a => $"'{a.gameObject.name}'")); + return new + { + success = false, + message = $"'{go.name}' has no Animator and {candidates.Length} of its children do " + + $"({names}). Target one of them directly." + }; + } + + return new { success = false, message = $"No Animator component on '{go.name}' or its children" }; + } + + /// + /// Names the object a response should report as changed. When resolution retargeted to a + /// descendant, the caller is told so - otherwise the response claims the wrapper changed. + /// + /// + /// A suffix disclosing that resolution retargeted to a descendant; empty when the target + /// carried the Animator itself, so responses that already read correctly are untouched. + /// + public static string ResolvedSuffix(GameObject target, Animator resolved) + { + return resolved.gameObject == target + ? string.Empty + : $" (on '{resolved.gameObject.name}', resolved from '{target.name}')"; + } + + public static string Describe(GameObject target, Animator resolved) + { + return resolved.gameObject == target + ? $"'{target.name}'" + : $"'{resolved.gameObject.name}' (resolved from '{target.name}')"; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs.meta b/MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs.meta new file mode 100644 index 0000000000..86c93340af --- /dev/null +++ b/MCPForUnity/Editor/Tools/Animation/AnimatorResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c010b345a1c8474e8423ff919f94c5f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs index e2955f82a0..13dc3a1775 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services.AssetGen; using MCPForUnity.Editor.Services.AssetGen.Providers; @@ -62,5 +63,51 @@ public static object ListProviders(string kind) } return new SuccessResponse($"{list.Count} {kind} provider(s).", new { providers = list }); } + + /// Return the same model snapshot as the panel; refreshes run without blocking. + public static object ListModels(ToolParams p, string kind, bool forceRefresh = false) + { + string provider = p.Get("provider")?.ToLowerInvariant(); + var providers = AssetGenProviders.List().Where(info => info.Kind == kind && (string.IsNullOrEmpty(provider) || info.Id == provider)).ToList(); + if (providers.Count == 0) return new ErrorResponse($"Unknown {kind} provider '{provider}'."); + if (forceRefresh && !providers.Any(info => info.Id == "fal" || info.Id == "openrouter" && kind == "image")) + return new ErrorResponse("Live refresh supports fal and OpenRouter images. Tripo and Meshy use bundled models."); + string search = p.Get("search") ?? ""; + string mode = p.Get("mode"); + int limit = p.GetInt("limit", 50) ?? 50, offset = p.GetInt("offset", 0) ?? 0; + if (limit < 1 || limit > 200 || offset < 0) return new ErrorResponse("Use limit=1..200 and offset>=0."); + var models = new List(); + var catalogs = new List(); + foreach (var info in providers) + { + bool fal = info.Id == "fal", router = info.Id == "openrouter" && kind == "image"; + if (fal) _ = FalModelCatalog.RefreshAsync(kind, forceRefresh); + if (router) _ = OpenRouterModelCatalog.RefreshAsync(forceRefresh); + foreach (var model in AssetGenModelCatalog.ForProvider(info.Id, kind)) + { + var modes = model.Modes ?? (kind == "audio" ? new[] { "text" } : new[] { "text", "image" }); + if (!string.IsNullOrEmpty(mode) && !modes.Contains(mode)) continue; + if ((model.Id + " " + model.Label + " " + model.UseCase).IndexOf(search, System.StringComparison.OrdinalIgnoreCase) < 0) continue; + models.Add(new + { + id = model.Id, label = model.Label, provider = model.Provider, kind = model.Kind, + use_case = model.UseCase, verified_at = model.VerifiedAt, + status = model.VerifiedAt != null ? "verified" : model.FromRefresh ? "discovered" : "unverified", + capabilities = modes, + max_duration_seconds = model.MaxDurationSeconds, license_type = model.LicenseType, + }); + } + catalogs.Add(new + { + provider = info.Id, source = fal ? FalModelCatalog.Source(kind) : router ? OpenRouterModelCatalog.Source : "bundled", + last_checked = fal ? FalModelCatalog.VerifiedAt(kind) : router ? OpenRouterModelCatalog.CheckedAt : null, + stale = fal ? FalModelCatalog.IsStale(kind) : !router || OpenRouterModelCatalog.IsStale, + refreshing = fal ? FalModelCatalog.IsRefreshing(kind) : router && OpenRouterModelCatalog.IsRefreshing, + refresh_error = fal ? FalModelCatalog.LastError(kind) : router ? OpenRouterModelCatalog.LastError : null, + }); + } + return new SuccessResponse("Model catalog. Repeat list_models while refreshing. Discovered models require compatibility verification; live endpoints are rechecked before generation.", + new { models = models.Skip(offset).Take(limit).ToList(), total = models.Count, offset, limit, has_more = offset < models.Count - limit, catalogs }); + } } } diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs index 598d7c4438..99219943f5 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs @@ -30,9 +30,11 @@ public static object HandleCommand(JObject @params) case "status": return AssetGenToolHelpers.Status(p, "Audio", 3.0); case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("audio"); + case "list_models": return AssetGenToolHelpers.ListModels(p, "audio"); + case "refresh_models": return AssetGenToolHelpers.ListModels(p, "audio", true); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers, list_models, refresh_models."); } } catch (NotSupportedException nse) @@ -57,9 +59,8 @@ private static object Generate(ToolParams p) if (string.IsNullOrWhiteSpace(prompt)) return new ErrorResponse("'prompt' is required for audio generation."); - // Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own - // default; a resolved id is passed through verbatim (the catalog default equals the - // adapter constant, so an omitted model is a no-op either way). + // Empty -> GUI-selected model -> current catalog default. Preserve the resolved ID; + // exact availability and the live request profile are checked before submission. string model = AssetGenModelCatalog.ResolveModel("audio", provider, p.Get("model")); var req = new AudioGenRequest diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs index 1da99d563a..2fbfd8a065 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs @@ -31,9 +31,11 @@ public static object HandleCommand(JObject @params) case "status": return AssetGenToolHelpers.Status(p, "Image", 2.0); case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("image"); + case "list_models": return AssetGenToolHelpers.ListModels(p, "image"); + case "refresh_models": return AssetGenToolHelpers.ListModels(p, "image", true); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers, list_models, refresh_models."); } } catch (NotSupportedException nse) diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs index 718c92a942..e5e1dc1504 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -30,9 +30,11 @@ public static object HandleCommand(JObject @params) case "status": return AssetGenToolHelpers.Status(p, "3D model", 3.0); case "cancel": return AssetGenToolHelpers.Cancel(p); case "list_providers": return AssetGenToolHelpers.ListProviders("model"); + case "list_models": return AssetGenToolHelpers.ListModels(p, "model"); + case "refresh_models": return AssetGenToolHelpers.ListModels(p, "model", true); case "": return new ErrorResponse("'action' parameter is required."); default: - return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers, list_models, refresh_models."); } } catch (NotSupportedException nse) diff --git a/MCPForUnity/Editor/Tools/Blender.meta b/MCPForUnity/Editor/Tools/Blender.meta new file mode 100644 index 0000000000..2f34970271 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d61fccfc880e4bd38412f8705fc73104 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs new file mode 100644 index 0000000000..2984a6714e --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs @@ -0,0 +1,936 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.Blender; +using MCPForUnity.Editor.Tools.AssetGen; +using MCPForUnity.Runtime.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Blender +{ + /// + /// Lets the Unity Editor talk straight to the BlenderMCP addon socket, so a Blender → Unity + /// handoff is one call (export → import through the shared model pipeline → place → normalize) + /// instead of an AI-orchestrated multi-step dance. Also drives the Blender Bridge panel in the + /// Asset Gen tab and the Window/MCP for Unity/Blender Bridge menu. Carries no API keys. + /// Socket and git work runs on the thread pool; Unity API calls happen after the await, back on + /// the editor thread (the bridge awaits handlers on Unity's synchronization context). + /// + [McpForUnityTool("blender_bridge", AutoRegister = false, Group = "asset_gen", + Description = "Bridge to a running Blender with the BlenderMCP addon: status, scene/object info, viewport " + + "screenshot, run Python in Blender, import a model (export → import → place → normalize), " + + "check the blender-mcp checkout for updates, and sync its addon into Blender.")] + public static class BlenderBridgeTool + { + private static readonly string[] ValidActions = + { "status", "scene_info", "object_info", "screenshot", "run_python", "import_model", "compare_screenshot", "setup_bloom", "check_updates", "sync_addon" }; + + private const string NotConfiguredMessage = + "The blender-mcp checkout is not set. Open Window > MCP for Unity > Generative > Blender Bridge " + + "and pick the folder that contains addon.py."; + + /// Entry point for the bridge: validates parameters, dispatches by action, and never throws. + public static async Task HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? "status").Trim().ToLowerInvariant(); + int timeout = Math.Max(5, Math.Min(3600, p.GetInt("timeout_seconds", 180) ?? 180)); + + try + { + switch (action) + { + case "status": return await StatusAsync(); + case "scene_info": + return new SuccessResponse("Retrieved Blender scene info.", + await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "get_scene_info", null, timeout)); + case "object_info": + { + string objectName = p.Get("object_name"); + if (string.IsNullOrWhiteSpace(objectName)) + return new ErrorResponse("'object_name' is required for object_info."); + JToken info = await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "get_object_info", + new JObject { ["object_name"] = objectName }, timeout); + return new SuccessResponse($"Retrieved info for '{objectName}'.", info); + } + case "screenshot": return await ScreenshotAsync(p, timeout); + case "run_python": + { + string code = p.Get("code"); + if (string.IsNullOrWhiteSpace(code)) return new ErrorResponse("'code' is required for run_python."); + string stdout = await BlenderSocketClient.RunPythonAsync(BlenderBridgePrefs.Endpoint, code, timeout); + return new SuccessResponse("Executed Python in Blender.", new { stdout }); + } + case "import_model": return await ImportModelAsync(p, timeout); + case "compare_screenshot": return await CompareScreenshotAsync(p, timeout); + case "setup_bloom": return ToBloomResponse(await SetupBloomAsync()); + case "check_updates": return await CheckUpdatesAsync(); + case "sync_addon": return SyncAddon(p.GetBool("force", false)); + default: + return new ErrorResponse($"Unknown action '{action}'. Valid: {string.Join(", ", ValidActions)}."); + } + } + catch (BlenderUnavailableException e) + { + return new ErrorResponse(e.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub($"blender_bridge '{action}' failed: {e.Message}")); + } + } + + // ------------------------------------------------------------------ status + + /// Probes the addon socket and reports checkout / installed-addon state without failing. + private static async Task StatusAsync() + { + BlenderEndpoint endpoint = BlenderBridgePrefs.Endpoint; + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + bool forkConfigured = BlenderBridgePrefs.IsForkConfigured; + string forkPath = BlenderBridgePrefs.ForkPath; + bool blenderInstalled = BlenderDetection.IsInstalled(); + + var (reachable, error) = await BlenderSocketClient.ProbeAsync(endpoint); + JToken scene = null; + if (reachable) + { + try { scene = await BlenderSocketClient.SendAsync(endpoint, "get_scene_info", null, 10); } + catch (Exception e) { error = e.Message; } + } + + string forkMd5 = forkAddon != null && File.Exists(forkAddon) ? FileMd5(forkAddon) : null; + string installedMd5 = installedAddon != null && File.Exists(installedAddon) ? FileMd5(installedAddon) : null; + + var data = new JObject + { + ["blender_reachable"] = reachable, + ["blender_installed"] = blenderInstalled, + ["endpoint"] = endpoint.ToString(), + ["error"] = reachable ? null : error, + ["scene_name"] = scene?["name"], + ["object_count"] = scene?["object_count"], + ["fork_configured"] = forkConfigured, + ["fork_path"] = forkPath, + ["fork_addon_found"] = forkMd5 != null, + ["installed_addon_path"] = installedAddon, + ["installed_addon_found"] = installedMd5 != null, + ["addon_in_sync"] = forkMd5 != null && installedMd5 != null && forkMd5 == installedMd5, + }; + string msg = reachable + ? $"Blender reachable. Scene '{scene?["name"]}' with {scene?["object_count"]} objects." + : "Blender not reachable."; + return new SuccessResponse(msg, data); + } + + // -------------------------------------------------------------- screenshot + + /// Asks Blender for an offscreen viewport render and optionally copies it under Assets/. + private static async Task ScreenshotAsync(ToolParams p, int timeout) + { + int maxSize = Math.Max(64, p.GetInt("max_size", 1000) ?? 1000); + string outputFolder = p.Get("output_folder"); + + string assetsRelativeFolder = null; + if (!string.IsNullOrWhiteSpace(outputFolder)) + { + // Canonicalizes and rejects anything that does not resolve under Assets/ (e.g. "Assets/../x"). + if (!AssetGenPaths.NormalizeOutputFolder(outputFolder, out assetsRelativeFolder, out string folderError)) + return new ErrorResponse(folderError); + } + + string dir = Path.Combine(ProjectRoot(), "Library", "BlenderBridge"); + Directory.CreateDirectory(dir); + string file = Path.Combine(dir, $"blender_viewport_{UniqueSuffix()}.png").Replace('\\', '/'); + + JToken result = await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "get_viewport_screenshot", + new JObject { ["max_size"] = maxSize, ["filepath"] = file, ["format"] = "png" }, timeout); + + if (!File.Exists(file)) + return new ErrorResponse($"Blender did not write a screenshot to {file}. Response: {result}"); + + string assetPath = null; + if (assetsRelativeFolder != null) + { + string absDir = AssetGenPaths.ToAbsolute(assetsRelativeFolder); + Directory.CreateDirectory(absDir); + assetPath = $"{assetsRelativeFolder}/{Path.GetFileName(file)}"; + File.Copy(file, Path.Combine(absDir, Path.GetFileName(file)), true); + AssetDatabase.ImportAsset(assetPath); + } + + return new SuccessResponse("Captured Blender viewport.", new JObject + { + ["path"] = file, + ["asset_path"] = assetPath, + ["width"] = result?["width"], + ["height"] = result?["height"], + ["method"] = result?["method"], + }); + } + + // ------------------------------------------------------------ import_model + + /// Exports from Blender, imports through the shared pipeline, then places and normalizes the instance. + private static async Task ImportModelAsync(ToolParams p, int timeout) + { + string fmt = (p.Get("format") ?? "glb").Trim().ToLowerInvariant(); + if (fmt != "glb" && fmt != "fbx") return new ErrorResponse("'format' must be glb or fbx."); + + string[] names = p.GetStringArray("object_names")?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); + bool selectionOnly = p.GetBool("selection_only", false); + bool applyModifiers = p.GetBool("apply_modifiers", true); + bool place = p.GetBool("place_in_scene", true); + float target = Mathf.Max(0f, p.GetFloat("target_size", 0f) ?? 0f); + string outputFolder = p.Get("output_folder"); + string animationType = p.Get("animation_type"); + bool autoAnimate = p.GetBool("auto_animate", true); + bool savePrefab = p.GetBool("save_prefab", false); + bool ensureBloom = p.GetBool("ensure_bloom", false); + + string name = p.Get("name"); + if (string.IsNullOrWhiteSpace(name)) + name = names != null && names.Length == 1 ? names[0] : "BlenderModel"; + name = SanitizeName(name); + + // 1. Export from Blender to a temp file (off the editor thread). + string exportDir = Path.Combine(Path.GetTempPath(), "BlenderBridge"); + Directory.CreateDirectory(exportDir); + string exportPath = Path.Combine(exportDir, $"{name}_{UniqueSuffix()}.{fmt}").Replace('\\', '/'); + + string stdout = await ExportFromBlenderAsync(BlenderBridgePrefs.Endpoint, exportPath, names, selectionOnly, applyModifiers, fmt, timeout); + + if (!File.Exists(exportPath)) + return new ErrorResponse($"Blender did not produce {exportPath}. Blender output: {Truncate(stdout, 800)}"); + + // 2. Import through the shared pipeline (staging under Assets/, glTFast/FBX, material setup). + // target_size 0 is passed through: the pipeline only rescales when it is > 0. + var importParams = new JObject + { + ["sourcePath"] = exportPath, + ["name"] = name, + ["targetSize"] = target, + }; + if (!string.IsNullOrWhiteSpace(outputFolder)) importParams["outputFolder"] = outputFolder; + if (!string.IsNullOrWhiteSpace(animationType)) importParams["animationType"] = animationType; + + JObject importResult = JObject.FromObject(ImportModelFile.HandleCommand(importParams)); + if (!(importResult.Value("success") ?? false)) + return new ErrorResponse($"Import failed: {importResult["error"] ?? importResult["message"]}"); + + string assetPath = importResult["data"]?["asset_path"]?.ToString(); + var data = new JObject + { + ["asset_path"] = assetPath, + ["asset_guid"] = importResult["data"]?["asset_guid"], + ["export_path"] = exportPath, + ["format"] = fmt, + ["blender_output"] = Truncate(stdout, 400), + ["placed"] = false, + }; + + if (!place || string.IsNullOrEmpty(assetPath)) + return new SuccessResponse($"Imported {assetPath} (not placed).", data); + + if (fmt == "fbx" && autoAnimate) ConfigureFbxClipLooping(assetPath); + + // 3. Place in the open scene and normalize size from measured bounds. Blender exports + // commonly land far off scale, so measuring the placed instance beats trusting the importer. + GameObject prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab == null) + { + data["note"] = "Asset has no GameObject root to instantiate."; + return new SuccessResponse($"Imported {assetPath} but could not instantiate it.", data); + } + + GameObject go = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? UnityEngine.Object.Instantiate(prefab); + go.name = name; + Undo.RegisterCreatedObjectUndo(go, "Import from Blender"); + go.transform.position = ParsePosition(p.GetRaw("position")); + + float scaleFactor = 1f; + if (target > 0f && TryGetWorldBounds(go, out Bounds b0)) + { + float maxDim = Mathf.Max(b0.size.x, Mathf.Max(b0.size.y, b0.size.z)); + if (maxDim > 1e-4f) + { + scaleFactor = target / maxDim; + go.transform.localScale *= scaleFactor; + } + } + + EditorSceneManager.MarkSceneDirty(go.scene); + Selection.activeGameObject = go; + + data["placed"] = true; + data["game_object"] = go.name; + data["scene"] = go.scene.name; + data["scale_factor_applied"] = scaleFactor; + if (TryGetWorldBounds(go, out Bounds b1)) + { + data["bounds_size"] = new JArray(b1.size.x, b1.size.y, b1.size.z); + data["bounds_center"] = new JArray(b1.center.x, b1.center.y, b1.center.z); + } + + // 4. Optional finishing touches: play imported clips, keep a prefab, make emission glow. + if (autoAnimate) + { + JObject animation = SetupAnimation(go, assetPath, name); + if (animation != null) data["animation"] = animation; + } + if (savePrefab) data["prefab_path"] = SavePrefab(go, assetPath, name); + if (ensureBloom && HasEmissiveMaterial(go)) data["bloom"] = await SetupBloomAsync(); + + return new SuccessResponse($"Imported {assetPath} and placed '{go.name}' in the scene.", data); + } + + /// + /// Asks the addon to export. Prefers its first-class export_scene command (validated + /// parameters, no code execution); addons that predate it answer "Unknown command type", in + /// which case the equivalent Python is sent through execute_code. + /// + private static async Task ExportFromBlenderAsync(BlenderEndpoint endpoint, string exportPath, string[] names, + bool selectionOnly, bool applyModifiers, string fmt, int timeout) + { + var args = new JObject + { + ["filepath"] = exportPath, + ["format"] = fmt, + ["object_names"] = names == null ? new JArray() : new JArray(names.Cast().ToArray()), + ["selection_only"] = selectionOnly, + ["apply_modifiers"] = applyModifiers, + }; + try + { + JToken result = await BlenderSocketClient.SendAsync(endpoint, "export_scene", args, timeout); + if (result?["error"] != null) throw new BlenderCommandException($"Blender export_scene failed: {result["error"]}"); + return result?.ToString(Formatting.None) ?? string.Empty; + } + catch (BlenderCommandException e) when (e.Message.Contains("Unknown command type")) + { + string script = BuildExportScript(exportPath, names, selectionOnly, applyModifiers, fmt); + return await BlenderSocketClient.RunPythonAsync(endpoint, script, timeout); + } + } + + /// + /// Builds the Python that Blender runs to export on addons without export_scene. All caller-controlled values travel inside one + /// JSON document embedded as a single Python string literal, so names with quotes or + /// placeholder-looking text can never alter the program. + /// + internal static string BuildExportScript(string outPath, string[] names, bool selectionOnly, bool applyModifiers, string fmt) + { + var config = new JObject + { + ["out"] = outPath, + ["names"] = names == null ? new JArray() : new JArray(names.Cast().ToArray()), + ["selection_only"] = selectionOnly, + ["apply_modifiers"] = applyModifiers, + ["format"] = fmt, + }; + // JSON string escaping is a subset of Python string-literal escaping, so the encoded + // document is a valid double-quoted Python literal. + string configLiteral = JsonConvert.ToString(config.ToString(Formatting.None)); + + const string template = @" +import bpy, os, json +cfg = json.loads(__CFG__) +out = cfg['out'] +names = cfg['names'] +selection_only = cfg['selection_only'] +apply_mods = cfg['apply_modifiers'] +fmt = cfg['format'] +os.makedirs(os.path.dirname(out), exist_ok=True) +try: + if bpy.context.object and bpy.context.object.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') +except Exception: + pass +use_sel = False +if names: + missing = [n for n in names if bpy.data.objects.get(n) is None] + if missing: + raise Exception('Objects not found in Blender: ' + ', '.join(missing)) + bpy.ops.object.select_all(action='DESELECT') + for n in names: + o = bpy.data.objects[n] + o.select_set(True) + for c in o.children_recursive: + c.select_set(True) + bpy.context.view_layer.objects.active = bpy.data.objects[names[0]] + use_sel = True +elif selection_only: + if not bpy.context.selected_objects: + raise Exception('Nothing is selected in Blender and no object_names were given.') + use_sel = True +if fmt == 'glb': + bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_selection=use_sel, + use_active_scene=True, export_apply=apply_mods, + export_animations=True, export_skins=True, export_morph=True, + export_yup=True) +else: + bpy.ops.export_scene.fbx(filepath=out, use_selection=use_sel, apply_unit_scale=True, + bake_space_transform=apply_mods, use_mesh_modifiers=apply_mods, + path_mode='COPY', embed_textures=True) +print(json.dumps({'path': out, 'bytes': os.path.getsize(out), 'selection_only': use_sel, + 'exported': [o.name for o in (bpy.context.selected_objects if use_sel else bpy.context.scene.objects)]})) +"; + // Verbatim strings take the checkout's line endings; normalize so the script (and tests) are + // byte-identical whether the file was checked out with LF or CRLF. + string body = template.Replace("\r\n", "\n"); + int at = body.IndexOf("__CFG__", StringComparison.Ordinal); + return body.Substring(0, at) + configLiteral + body.Substring(at + "__CFG__".Length); + } + + // ---------------------------------------------------------- finishing touches + + /// Marks every clip in an FBX as looping so the imported animation cycles once a controller drives it. + private static void ConfigureFbxClipLooping(string assetPath) + { + if (!(AssetImporter.GetAtPath(assetPath) is ModelImporter importer)) return; + ModelImporterClipAnimation[] clips = importer.clipAnimations.Length > 0 ? importer.clipAnimations : importer.defaultClipAnimations; + if (clips.Length == 0) return; + foreach (ModelImporterClipAnimation clip in clips) clip.loopTime = true; + importer.clipAnimations = clips; + importer.SaveAndReimport(); + } + + /// + /// An imported clip does nothing until an AnimatorController drives it, so the placed model looks frozen. + /// Creates a controller next to the asset with one looping state per clip and assigns it to the instance. + /// + private static JObject SetupAnimation(GameObject go, string assetPath, string name) + { + AnimationClip[] clips = AssetDatabase.LoadAllAssetRepresentationsAtPath(assetPath) + .OfType() + .Where(c => !c.name.StartsWith("__preview__", StringComparison.Ordinal)) + .ToArray(); + if (clips.Length == 0) return null; + + Animator animator = go.GetComponentInChildren(true) ?? go.AddComponent(); + string controllerPath = AssetDatabase.GenerateUniqueAssetPath( + Path.Combine(Path.GetDirectoryName(assetPath) ?? "Assets", $"{name}_Controller.controller").Replace('\\', '/')); + var controller = UnityEditor.Animations.AnimatorController.CreateAnimatorControllerAtPath(controllerPath); + var stateMachine = controller.layers[0].stateMachine; + foreach (AnimationClip clip in clips) + { + AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip); + if (!settings.loopTime) + { + settings.loopTime = true; + AnimationUtility.SetAnimationClipSettings(clip, settings); + } + var state = stateMachine.AddState(clip.name); + state.motion = clip; + if (stateMachine.defaultState == null) stateMachine.defaultState = state; + } + animator.runtimeAnimatorController = controller; + EditorUtility.SetDirty(controller); + EditorUtility.SetDirty(animator); + AssetDatabase.SaveAssets(); + return new JObject + { + ["clips"] = new JArray(clips.Select(c => c.name).Cast().ToArray()), + ["controller_path"] = controllerPath, + ["animator_on"] = animator.gameObject.name, + ["loops"] = true, + }; + } + + /// Saves the placed instance as a prefab under <asset folder>/Prefabs and connects the instance to it. + private static string SavePrefab(GameObject go, string assetPath, string name) + { + string parent = (Path.GetDirectoryName(assetPath) ?? "Assets").Replace('\\', '/'); + string dir = $"{parent}/Prefabs"; + if (!AssetDatabase.IsValidFolder(dir)) AssetDatabase.CreateFolder(parent, "Prefabs"); + string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{dir}/{name}.prefab"); + PrefabUtility.SaveAsPrefabAssetAndConnect(go, prefabPath, InteractionMode.AutomatedAction); + return prefabPath; + } + + /// True when any material on the hierarchy has a non-black emission (glTFast or URP Lit naming). + private static bool HasEmissiveMaterial(GameObject go) + { + foreach (Renderer r in go.GetComponentsInChildren(true)) + { + foreach (Material m in r.sharedMaterials) + { + if (m == null) continue; + if (m.HasProperty("emissiveFactor") && m.GetColor("emissiveFactor").maxColorComponent > 0.01f) return true; + if (m.HasProperty("_EmissionColor") && m.IsKeywordEnabled("_EMISSION") && m.GetColor("_EmissionColor").maxColorComponent > 0.01f) return true; + } + } + return false; + } + + /// + /// Makes emissive materials actually glow: enables post-processing on the main camera and ensures a + /// global Volume with a Bloom override exists (reusing the scene's global volume when it has one). + /// Goes through manage_graphics so the Volume/Bloom reflection lives in one place. + /// + private static async Task SetupBloomAsync() + { + var result = new JObject(); + Type volumeType = Type.GetType("UnityEngine.Rendering.Volume, Unity.RenderPipelines.Core.Runtime"); + if (volumeType == null) + { + result["message"] = "Volume system (URP/HDRP) not available; nothing to do."; + return result; + } + + Camera cam = Camera.main; + Type camDataType = Type.GetType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData, Unity.RenderPipelines.Universal.Runtime"); + if (cam != null && camDataType != null) + { + Component camData = cam.GetComponent(camDataType) ?? cam.gameObject.AddComponent(camDataType); + System.Reflection.PropertyInfo post = camDataType.GetProperty("renderPostProcessing"); + if (post != null && !(bool)post.GetValue(camData)) + { + post.SetValue(camData, true); + EditorUtility.SetDirty(camData); + result["camera_post_processing_enabled"] = cam.name; + } + } + + System.Reflection.PropertyInfo isGlobal = volumeType.GetProperty("isGlobal"); + System.Reflection.FieldInfo sharedProfile = volumeType.GetField("sharedProfile"); + Component globalVolume = null; + foreach (UnityEngine.Object v in UnityFindObjectsCompat.FindAll(volumeType)) + { + var comp = v as Component; + if (comp == null || (isGlobal != null && !(bool)isGlobal.GetValue(comp))) continue; + globalVolume ??= comp; + object profile = sharedProfile?.GetValue(comp); + var components = profile?.GetType().GetField("components")?.GetValue(profile) as System.Collections.IEnumerable; + if (components != null && components.Cast().Any(c => c != null && c.GetType().Name == "Bloom")) + { + result["volume"] = comp.gameObject.name; + result["message"] = $"Bloom already present on global volume '{comp.gameObject.name}'."; + return result; + } + } + + JObject graphics; + if (globalVolume != null) + { + graphics = JObject.FromObject(await CommandRegistry.InvokeCommandAsync("manage_graphics", new JObject + { + ["action"] = "volume_add_effect", ["target"] = globalVolume.gameObject.name, ["effect"] = "Bloom", + })); + if (graphics.Value("success") ?? false) + { + await CommandRegistry.InvokeCommandAsync("manage_graphics", new JObject + { + ["action"] = "volume_set_effect", ["target"] = globalVolume.gameObject.name, ["effect"] = "Bloom", + ["parameters"] = new JObject { ["intensity"] = 1.0f, ["threshold"] = 0.9f }, + }); + } + result["volume"] = globalVolume.gameObject.name; + } + else + { + graphics = JObject.FromObject(await CommandRegistry.InvokeCommandAsync("manage_graphics", new JObject + { + ["action"] = "volume_create", ["name"] = "Global Volume (Blender Bridge)", ["is_global"] = true, + ["effects"] = new JArray(new JObject { ["type"] = "Bloom", ["intensity"] = 1.0f, ["threshold"] = 0.9f }), + })); + result["volume"] = "Global Volume (Blender Bridge)"; + } + bool ok = graphics.Value("success") ?? false; + result["message"] = ok ? $"Bloom added to '{result["volume"]}'." : $"Could not add Bloom: {graphics["error"] ?? graphics["message"]}"; + result["success"] = ok; + return result; + } + + /// Maps the Bloom setup report to a tool response: an error when manage_graphics could not add Bloom. + internal static object ToBloomResponse(JObject bloom) + { + string message = bloom?["message"]?.ToString() ?? "Bloom configured."; + bool failed = bloom != null && bloom.Value("success") == false; + return failed ? new ErrorResponse(message) : new SuccessResponse(message, bloom); + } + + // -------------------------------------------------------- compare_screenshot + + /// Puts a Blender viewport capture and a Unity capture of a placed object side by side in one PNG. + private static async Task CompareScreenshotAsync(ToolParams p, int timeout) + { + string target = p.Get("game_object"); + if (string.IsNullOrWhiteSpace(target)) return new ErrorResponse("'game_object' is required for compare_screenshot."); + if (GameObject.Find(target) == null) return new ErrorResponse($"GameObject '{target}' not found in the open scene."); + int maxSize = Math.Max(128, p.GetInt("max_size", 800) ?? 800); + string outputFolder = p.Get("output_folder"); + string assetsRelativeFolder = null; + if (!string.IsNullOrWhiteSpace(outputFolder) + && !AssetGenPaths.NormalizeOutputFolder(outputFolder, out assetsRelativeFolder, out string folderError)) + return new ErrorResponse(folderError); + + string dir = Path.Combine(ProjectRoot(), "Library", "BlenderBridge"); + Directory.CreateDirectory(dir); + string blenderPng = Path.Combine(dir, $"compare_blender_{UniqueSuffix()}.png").Replace('\\', '/'); + await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "get_viewport_screenshot", + new JObject { ["max_size"] = maxSize, ["filepath"] = blenderPng, ["format"] = "png" }, timeout); + if (!File.Exists(blenderPng)) return new ErrorResponse("Blender did not write a viewport screenshot."); + + JObject shot = JObject.FromObject(await CommandRegistry.InvokeCommandAsync("manage_scene", new JObject + { + ["action"] = "screenshot", ["view_target"] = target, ["max_resolution"] = maxSize, + ["screenshot_file_name"] = $"blender_bridge_compare_{UniqueSuffix()}", + })); + string unityPng = shot["data"]?["fullPath"]?.ToString(); + if (!(shot.Value("success") ?? false) || string.IsNullOrEmpty(unityPng) || !File.Exists(unityPng)) + return new ErrorResponse($"Unity screenshot failed: {shot["error"] ?? shot["message"]}"); + + string outPath = Path.Combine(dir, $"compare_{UniqueSuffix()}.png").Replace('\\', '/'); + (int width, int height) = CompositeSideBySide(blenderPng, unityPng, outPath); + + // The Unity capture is written under Assets/ by manage_scene; keep the project clean. + string unityAssetPath = shot["data"]?["path"]?.ToString(); + if (!string.IsNullOrEmpty(unityAssetPath) && unityAssetPath.StartsWith("Assets/", StringComparison.Ordinal)) + AssetDatabase.DeleteAsset(unityAssetPath); + + string assetPath = null; + if (assetsRelativeFolder != null) + { + string absDir = AssetGenPaths.ToAbsolute(assetsRelativeFolder); + Directory.CreateDirectory(absDir); + assetPath = $"{assetsRelativeFolder}/{Path.GetFileName(outPath)}"; + File.Copy(outPath, Path.Combine(absDir, Path.GetFileName(outPath)), true); + AssetDatabase.ImportAsset(assetPath); + } + + return new SuccessResponse("Composited Blender (left) and Unity (right).", new JObject + { + ["path"] = outPath, ["asset_path"] = assetPath, ["width"] = width, ["height"] = height, + ["blender_path"] = blenderPng, ["game_object"] = target, + }); + } + + /// Scales both images to the smaller height and writes them side by side. Returns the output size. + internal static (int Width, int Height) CompositeSideBySide(string leftPng, string rightPng, string outPath) + { + Texture2D left = LoadPng(leftPng), right = LoadPng(rightPng); + Texture2D l = null, r = null, outTex = null; + try + { + int h = Math.Min(left.height, right.height); + l = ScaleToHeight(left, h); + r = ScaleToHeight(right, h); + outTex = new Texture2D(l.width + r.width, h, TextureFormat.RGBA32, false); + outTex.SetPixels(0, 0, l.width, h, l.GetPixels()); + outTex.SetPixels(l.width, 0, r.width, h, r.GetPixels()); + outTex.Apply(); + File.WriteAllBytes(outPath, outTex.EncodeToPNG()); + return (outTex.width, h); + } + finally + { + foreach (Texture2D t in new[] { outTex, l == left ? null : l, r == right ? null : r, left, right }) + if (t != null) UnityEngine.Object.DestroyImmediate(t); + } + } + + /// Decodes a PNG from disk into a readable texture. + private static Texture2D LoadPng(string path) + { + var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!ImageConversion.LoadImage(tex, File.ReadAllBytes(path))) + { + UnityEngine.Object.DestroyImmediate(tex); + throw new IOException($"Could not decode PNG: {path}"); + } + return tex; + } + + /// Bilinear resample to a target height, keeping the aspect ratio. Returns the source when already right. + private static Texture2D ScaleToHeight(Texture2D src, int height) + { + if (src.height == height) return src; + int width = Mathf.Max(1, Mathf.RoundToInt(src.width * (float)height / src.height)); + var dst = new Texture2D(width, height, TextureFormat.RGBA32, false); + var pixels = new Color[width * height]; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + pixels[y * width + x] = src.GetPixelBilinear((x + 0.5f) / width, (y + 0.5f) / height); + dst.SetPixels(pixels); + dst.Apply(); + return dst; + } + + // ----------------------------------------------------------- check_updates + + /// Fetches the checkout's remotes on the thread pool and reports how far behind it is. + private static async Task CheckUpdatesAsync() + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string fork = BlenderBridgePrefs.ForkPath; + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + if (!Directory.Exists(Path.Combine(fork, ".git"))) + return new ErrorResponse($"'{fork}' is not a git checkout (no .git folder); check_updates needs one."); + + return await Task.Run(() => CheckUpdatesBlocking(fork, forkAddon, installedAddon)); + } + + /// Git and file work for check_updates; touches no Unity API so it can run on any thread. + internal static object CheckUpdatesBlocking(string fork, string forkAddon, string installedAddon) + { + if (!TryGit(fork, "--version", out _, out string gitErr, 10000)) + return new ErrorResponse($"git is not available: {gitErr}"); + + TryGit(fork, "log -1 --format=%h%x09%ad%x09%s --date=short", out string head, out _); + TryGit(fork, "status --porcelain", out string porcelain, out _); + TryGit(fork, "remote", out string remotesRaw, out _); + var remotes = remotesRaw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(r => r.Trim()).Where(r => r.Length > 0).ToList(); + + var perRemote = new JArray(); + int totalBehind = 0; + foreach (string remote in remotes.OrderBy(r => r == "upstream" ? 0 : r == "origin" ? 1 : 2)) + { + var entry = new JObject { ["remote"] = remote }; + TryGit(fork, $"remote get-url {remote}", out string url, out _); + entry["url"] = RedactRemoteUrl(url.Trim()); + + bool fetched = TryGit(fork, $"fetch --quiet {remote}", out _, out string fetchErr, 90000); + entry["fetched"] = fetched; + if (!fetched) entry["fetch_error"] = Truncate(fetchErr, 300); + + string branch = "main"; + if (TryGit(fork, $"symbolic-ref --short refs/remotes/{remote}/HEAD", out string sym, out _) && sym.Trim().Contains('/')) + branch = sym.Trim().Substring(sym.Trim().IndexOf('/') + 1); + else if (!TryGit(fork, $"rev-parse --verify --quiet refs/remotes/{remote}/main", out _, out _) + && TryGit(fork, $"rev-parse --verify --quiet refs/remotes/{remote}/master", out _, out _)) + branch = "master"; + entry["branch"] = branch; + + if (TryGit(fork, $"rev-list --left-right --count HEAD...{remote}/{branch}", out string counts, out string cErr)) + { + var parts = counts.Trim().Split('\t', ' '); + int ahead = parts.Length > 0 && int.TryParse(parts[0], out int a) ? a : 0; + int behind = parts.Length > 1 && int.TryParse(parts[1], out int bb) ? bb : 0; + entry["local_ahead"] = ahead; + entry["behind"] = behind; + if (remote == "upstream" || remotes.Count == 1) totalBehind += behind; + + TryGit(fork, $"log --format=%h%x20%ad%x20%s --date=short -n 20 HEAD..{remote}/{branch}", out string log, out _); + entry["new_commits"] = new JArray(log.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); + } + else + { + entry["error"] = Truncate(cErr, 300); + } + perRemote.Add(entry); + } + + string forkMd5 = File.Exists(forkAddon) ? FileMd5(forkAddon) : null; + string installedMd5 = installedAddon != null && File.Exists(installedAddon) ? FileMd5(installedAddon) : null; + bool addonInSync = forkMd5 != null && forkMd5 == installedMd5; + + int dirty = porcelain.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Length; + var recommendations = new List(); + if (totalBehind > 0) recommendations.Add($"Checkout is {totalBehind} commit(s) behind upstream: merge upstream into it."); + if (!addonInSync) recommendations.Add("Installed Blender addon differs from the checkout's addon.py: run sync_addon, then restart Blender."); + if (dirty > 0) recommendations.Add($"Checkout has {dirty} uncommitted change(s)."); + if (recommendations.Count == 0) recommendations.Add("Everything is up to date."); + + var data = new JObject + { + ["fork_path"] = fork, + ["head"] = head.Trim(), + ["uncommitted_changes"] = dirty, + ["remotes"] = perRemote, + ["fork_addon_md5"] = forkMd5, + ["installed_addon_path"] = installedAddon, + ["installed_addon_md5"] = installedMd5, + ["addon_in_sync"] = addonInSync, + ["recommendations"] = new JArray(recommendations), + }; + return new SuccessResponse(string.Join(" ", recommendations), data); + } + + // -------------------------------------------------------------- sync_addon + + /// Copies the checkout's addon.py over Blender's installed copy, keeping a .bak of the old file. + private static object SyncAddon(bool force) + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string src = BlenderBridgePrefs.ForkAddonPath; + string dst = BlenderBridgePrefs.InstalledAddonPath; + if (!File.Exists(src)) return new ErrorResponse($"addon.py not found in the checkout at {src}."); + if (dst == null) + return new ErrorResponse("Could not locate Blender's user addons directory. Set it in Window > MCP for Unity > Generative > Blender Bridge."); + + string srcMd5 = FileMd5(src); + string dstMd5 = File.Exists(dst) ? FileMd5(dst) : null; + if (!force && srcMd5 == dstMd5) + return new SuccessResponse("Installed addon already matches the checkout; nothing copied.", + new { source = src, destination = dst, md5 = srcMd5, copied = false }); + + Directory.CreateDirectory(Path.GetDirectoryName(dst)); + string backup = null; + if (File.Exists(dst)) + { + backup = dst + ".bak"; + File.Copy(dst, backup, true); + } + File.Copy(src, dst, true); + + return new SuccessResponse( + "Copied addon.py into Blender. Restart Blender (or Reload Scripts) and press 'Connect to MCP server' again.", + new { source = src, destination = dst, backup, previous_md5 = dstMd5, new_md5 = srcMd5, copied = true }); + } + + // ------------------------------------------------------------------ helpers + + /// Runs one git command with no prompts; false on non-zero exit, timeout, or missing git. + private static bool TryGit(string workingDir, string args, out string stdout, out string stderr, int timeoutMs = 30000) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + var psi = new ProcessStartInfo("git", args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0"; + using var proc = Process.Start(psi); + if (proc == null) { stderr = "failed to start git"; return false; } + var outTask = proc.StandardOutput.ReadToEndAsync(); + var errTask = proc.StandardError.ReadToEndAsync(); + if (!proc.WaitForExit(timeoutMs)) + { + try { proc.Kill(); } catch { /* already gone */ } + stderr = $"git timed out after {timeoutMs} ms"; + return false; + } + // Git diagnostics may echo credential-bearing URLs (including redirects), + // helper output or bare tokens. Do not send them to MCP clients or editor logs. + // Drain both pipes, but only expose stdout from a successful command. + string output = outTask.Result; + _ = errTask.Result; + if (proc.ExitCode != 0) + { + stderr = $"git exited with code {proc.ExitCode}. Check the checkout and remote configuration locally."; + return false; + } + stdout = output; + return true; + } + catch (Exception) + { + stderr = "Unable to run git. Check that git is installed and the checkout is accessible."; + return false; + } + } + + /// + /// Timestamp plus a random tail for temp file names, so concurrent commands (menu + MCP) started + /// in the same second never share a screenshot or export path. + /// + private static string UniqueSuffix() + => $"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + /// + /// Strips user info, query and fragment from a remote URL so credentials embedded as + /// https://user:token@host/..., ?token=... or #access_token=... never reach the MCP response + /// or the editor log. scp-style git@host:path is returned unchanged (no secret there). + /// + internal static string RedactRemoteUrl(string url) + { + if (string.IsNullOrEmpty(url)) return url ?? string.Empty; + if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri) && !string.IsNullOrEmpty(uri.Host)) + { + bool clean = string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.Query) && string.IsNullOrEmpty(uri.Fragment); + if (clean) return url; + return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty, Query = string.Empty, Fragment = string.Empty } + .Uri.ToString(); + } + // Not a parseable absolute URI: drop anything after ? or # and mask a user:secret@ prefix. + int cut = url.IndexOfAny(new[] { '?', '#' }); + if (cut >= 0) url = url.Substring(0, cut); + int scheme = url.IndexOf("://", StringComparison.Ordinal); + int at = url.IndexOf('@'); + if (scheme >= 0 && at > scheme) + return url.Substring(0, scheme + 3) + "***@" + url.Substring(at + 1); + return url; + } + + /// Absolute project folder (parent of Assets/), forward slashes. + private static string ProjectRoot() => Path.GetDirectoryName(Application.dataPath).Replace('\\', '/'); + + /// Lower-case hex MD5 of a file; shared with the Asset Gen panel's addon-sync indicator. + internal static string FileMd5(string path) + { + using var md5 = MD5.Create(); + using var fs = File.OpenRead(path); + return BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", "").ToLowerInvariant(); + } + + /// Makes a caller-supplied name safe to use as a file and GameObject name. + private static string SanitizeName(string raw) + { + var invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(); + foreach (char c in raw.Trim()) + sb.Append(invalid.Contains(c) || c == '/' || c == '\\' ? '_' : c); + string s = sb.ToString().Trim('_', '.', ' '); + return string.IsNullOrEmpty(s) ? "BlenderModel" : s; + } + + /// Reads a [x, y, z] position from a JSON array or a stringified array; origin when absent. + private static Vector3 ParsePosition(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return Vector3.zero; + JArray arr = token as JArray; + if (arr == null && token.Type == JTokenType.String) + { + try { arr = JArray.Parse(token.ToString()); } catch { return Vector3.zero; } + } + if (arr == null || arr.Count < 3) return Vector3.zero; + return new Vector3(arr[0].Value(), arr[1].Value(), arr[2].Value()); + } + + /// World-space bounds over every renderer in the hierarchy; false when it has none. + private static bool TryGetWorldBounds(GameObject go, out Bounds bounds) + { + var renderers = go.GetComponentsInChildren(true); + if (renderers.Length == 0) + { + bounds = default; + return false; + } + bounds = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds); + return true; + } + + /// Trims and caps a string for inclusion in messages. + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + s = s.Trim(); + return s.Length <= max ? s : s.Substring(0, max) + "…"; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta new file mode 100644 index 0000000000..34c3f064f1 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b800c597408744668544a1b31f757ddd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index 8f9c64ec87..8383501b39 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -14,7 +14,6 @@ #if USE_ROSLYN using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Formatting; #endif #if UNITY_EDITOR @@ -740,16 +739,6 @@ private static object ApplyTextEdits( int endLineRos = firstLine + 5; return new ErrorResponse("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); } - - // Optional formatting - try - { - var root = tree.GetRoot(); - var workspace = new AdhocWorkspace(); - root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); - working = root.ToFullString(); - } - catch { } } #endif @@ -2361,7 +2350,7 @@ private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel bool hasErrors = false; foreach (var diagnostic in diagnostics) { - string severity = diagnostic.Severity.ToString().ToUpper(); + string severity = diagnostic.Severity.ToString().ToUpperInvariant(); string message = $"{severity}: {diagnostic.GetMessage()}"; if (diagnostic.Severity == DiagnosticSeverity.Error) diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index b7ee1772a6..6fa354178e 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -30,6 +30,19 @@ public static class ReadConsole private static FieldInfo _messageField; private static FieldInfo _fileField; private static FieldInfo _lineField; + + // Optional reflection members: used to neutralize the Console window's own filters + // while reading. Absent members degrade to the previous (filter-inheriting) behavior. + private static PropertyInfo _consoleFlagsProperty; + private static MethodInfo _setFilteringTextMethod; + private static MethodInfo _getFilteringTextMethod; + + // Severity bits from the internal UnityEditor.ConsoleWindow.ConsoleFlags enum. + private const int ConsoleFlagLogLevelLog = 1 << 7; + private const int ConsoleFlagLogLevelWarning = 1 << 8; + private const int ConsoleFlagLogLevelError = 1 << 9; + private const int ConsoleFlagLogLevelMask = + ConsoleFlagLogLevelLog | ConsoleFlagLogLevelWarning | ConsoleFlagLogLevelError; // Static constructor for reflection setup static ReadConsole() @@ -99,6 +112,13 @@ static ReadConsole() if (_lineField == null) throw new Exception("Failed to reflect LogEntry.line"); + // Console window UI state. Present on every Unity version this package + // supports, but reflected optionally so a future rename degrades to the old + // behavior rather than disabling console reads outright. + _consoleFlagsProperty = logEntriesType.GetProperty("consoleFlags", staticFlags); + _setFilteringTextMethod = logEntriesType.GetMethod("SetFilteringText", staticFlags); + _getFilteringTextMethod = logEntriesType.GetMethod("GetFilteringText", staticFlags); + // (Calibration removed) } @@ -115,6 +135,8 @@ static ReadConsole() _getEntryMethod = null; _modeField = _messageField = _fileField = _lineField = null; + _consoleFlagsProperty = null; + _setFilteringTextMethod = _getFilteringTextMethod = null; } } @@ -202,6 +224,99 @@ public static object HandleCommand(JObject @params) // --- Action Implementations --- + /// + /// Forces the Console window's Log/Warning/Error severity bits on so that + /// StartGettingEntries reports every entry regardless of the toolbar toggles. + /// + /// The flags as they were, for restoration. + /// True when the flags were changed and must be restored. + private static bool TryForceLogLevelFlags(out int savedConsoleFlags) + { + savedConsoleFlags = 0; + if (_consoleFlagsProperty == null) + { + return false; + } + + try + { + savedConsoleFlags = (int)_consoleFlagsProperty.GetValue(null); + int forcedFlags = savedConsoleFlags | ConsoleFlagLogLevelMask; + if (forcedFlags == savedConsoleFlags) + { + return false; // Nothing is hidden; leave the property untouched. + } + + _consoleFlagsProperty.SetValue(null, forcedFlags); + return true; + } + catch (Exception e) + { + McpLog.Warn( + $"[ReadConsole] Could not override console severity flags; entries hidden by the Console window may be missing: {e.Message}" + ); + return false; + } + } + + private static void RestoreConsoleFlags(int savedConsoleFlags) + { + try + { + _consoleFlagsProperty.SetValue(null, savedConsoleFlags); + } + catch (Exception e) + { + McpLog.Error($"[ReadConsole] Failed to restore console severity flags: {e}"); + } + } + + /// + /// Clears the Console window's search query for the duration of a read. Only clears it + /// when it can also be read back, so a user's search is never silently discarded. + /// + /// The query as it was, for restoration. + /// True when the query was cleared and must be restored. + private static bool TryClearFilteringText(out string savedFilteringText) + { + savedFilteringText = null; + if (_setFilteringTextMethod == null || _getFilteringTextMethod == null) + { + return false; + } + + try + { + savedFilteringText = _getFilteringTextMethod.Invoke(null, null) as string; + if (string.IsNullOrEmpty(savedFilteringText)) + { + return false; // No search query active; nothing to neutralize. + } + + _setFilteringTextMethod.Invoke(null, new object[] { string.Empty }); + return true; + } + catch (Exception e) + { + McpLog.Warn( + $"[ReadConsole] Could not clear the console search filter; entries hidden by it may be missing: {e.Message}" + ); + return false; + } + } + + private static void RestoreFilteringText(string savedFilteringText) + { + try + { + _setFilteringTextMethod.Invoke(null, new object[] { savedFilteringText }); + } + catch (Exception e) + { + McpLog.Error($"[ReadConsole] Failed to restore the console search filter: {e}"); + } + } + private static object ClearConsole() { try @@ -246,8 +361,20 @@ bool includeStacktrace int resolvedCursor = Mathf.Max(0, cursor ?? 0); int pageEndExclusive = resolvedCursor + resolvedPageSize; + // LogEntries filtering state is global and shared with the Console window, so a + // severity toggle switched off or a leftover search query in the toolbar silently + // starves this tool of entries. Neutralize both for the duration of the read; the + // tool applies its own 'types' and 'filterText' arguments instead. + int savedConsoleFlags = 0; + bool consoleFlagsOverridden = false; + string savedFilteringText = null; + bool filteringTextOverridden = false; + try { + consoleFlagsOverridden = TryForceLogLevelFlags(out savedConsoleFlags); + filteringTextOverridden = TryClearFilteringText(out savedFilteringText); + // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal. // StartGettingEntries() returns the entry count — use it instead of GetCount() // which may return stale values within an active iteration session. @@ -392,6 +519,18 @@ bool includeStacktrace McpLog.Error($"[ReadConsole] Failed to call EndGettingEntries: {e}"); // Don't return error here as we might have valid data, but log it. } + + // Restore the Console window's filters once the iteration session is closed, + // so the user's view is left exactly as they had it. + if (filteringTextOverridden) + { + RestoreFilteringText(savedFilteringText); + } + + if (consoleFlagsOverridden) + { + RestoreConsoleFlags(savedConsoleFlags); + } } if (usePaging) diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs index e35237d805..01fbff0016 100644 --- a/MCPForUnity/Editor/Tools/RefreshUnity.cs +++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs @@ -18,6 +18,18 @@ public static class RefreshUnity { private const int DefaultWaitTimeoutSeconds = 60; + /// Backstop on the wait for compilation to begin. Not the normal + /// exit: RequestScriptCompilation records a pending request that the + /// editor drains into a pipeline run on a later tick whether or not any + /// source changed — "recompiles those scripts which require it" in the docs + /// describes per-assembly skipping inside that run, not a run that is skipped. + /// With nothing changed, 6000.3 still raises compilationStarted/Finished + /// (~100 ms, cached) and reloads the domain. The grace only bounds the cases + /// where the run never begins: the pipeline refusing to start on a setup + /// error, or play mode with "Recompile After Finished Playing" deferring it + /// until exit. Both are reported as compile_started = false. + private const int CompileStartGraceSeconds = 10; + public static async Task HandleCommand(JObject @params) { string mode = @params?["mode"]?.ToString() ?? "if_dirty"; @@ -36,6 +48,7 @@ public static async Task HandleCommand(JObject @params) bool refreshTriggered = false; bool compileRequested = false; + int compileCountBefore = EditorStateCache.CompileCount; try { @@ -77,6 +90,34 @@ public static async Task HandleCommand(JObject @params) return new ErrorResponse($"refresh_failed: {ex.Message}"); } + // RequestScriptCompilation only queues; the pipeline starts on a later + // editor tick. Sampling the state here therefore reported "idle" for a + // compile that was about to run, and the caller's readiness poll — which + // begins the moment this returns — saw a ready editor and returned + // immediately, so wait_for_ready silently did nothing for exactly the call + // it exists for (issue #814). Waiting for the start edge first makes + // resulting_state, and every readiness decision downstream of it, truthful. + // + // Unlike WaitForUnityReadyAsync this cannot span a domain reload: it + // resolves the moment compilation *starts*, long before assemblies swap. + // That is why it is safe on Unity 6+ where waiting for readiness is not. + // + // Gated on wait_for_ready: that flag is documented as the non-blocking + // switch, and this wait is a wait — cheap when the pipeline starts on the + // next tick, but a full grace when it never does. A caller who opted out + // of waiting gets the immediate return and the poll hint. + // + // compile_started is null when nothing was waited for (no compile + // requested, or wait_for_ready=false), so "not observed" never reads as + // "did not start". + bool? compileStarted = null; + if (compileRequested && waitForReady) + { + compileStarted = await WaitForCompilationToStartAsync( + compileCountBefore, + TimeSpan.FromSeconds(CompileStartGraceSeconds)).ConfigureAwait(true); + } + // Unity 6+ fix: Skip wait_for_ready when compile was requested. // The EditorApplication.update polling in WaitForUnityReadyAsync doesn't survive // domain reloads properly in Unity 6+, causing infinite compilation loops. @@ -100,6 +141,7 @@ await WaitForUnityReadyAsync( { refresh_triggered = refreshTriggered, compile_requested = compileRequested, + compile_started = compileStarted, resulting_state = "unknown", }); } @@ -117,6 +159,7 @@ await WaitForUnityReadyAsync( { refresh_triggered = refreshTriggered, compile_requested = compileRequested, + compile_started = compileStarted, resulting_state = resultingState, hint = shouldWaitForReady ? "Unity refresh completed; editor should be ready." @@ -124,6 +167,92 @@ await WaitForUnityReadyAsync( }); } + /// + /// Resolves true once a compilation is under way, or false once + /// the grace elapsed without one. Two of the three exits are a start: + /// + /// the pipeline is running; + /// moved past + /// — a short compile can begin and end + /// inside AssetDatabase.Refresh, before this is even armed, and the counter is + /// the only thing that still sees it; + /// the grace elapsed with neither — the pipeline declined or deferred + /// the request (see ). + /// + /// The first two are also tested synchronously on entry, so the case where a + /// reload is already imminent never leaves this command queued as a + /// continuation — see the note on the fast path below. The running case is + /// observed from and + /// completed so that the caller's continuations run inline in that handler, + /// for the reason given at the completion source. + /// + internal static Task WaitForCompilationToStartAsync(int compileCountBefore, TimeSpan grace) + { + // Synchronous fast path, and the reason it matters: the counter check is + // there for a compile that began *and ended* inside AssetDatabase.Refresh + // above, and in that state the domain reload is already imminent. Resolving + // it from a later tick would hand the rest of this command to the + // synchronization context as a queued continuation, which the reload + // discards along with the rest of the domain — losing the response. An + // already-completed task resumes the await inline instead, so nothing is + // left queued. + if (EditorStateCache.CompileCount != compileCountBefore + || EditorStateCache.GetActualIsCompiling()) + { + return Task.FromResult(true); + } + + // Resolved from the compilationStarted event itself, not from a poll, and + // deliberately *without* RunContinuationsAsynchronously. Both matter for + // the same reason: the response has to be on the wire before the compile + // finishes, because the domain reload follows compilationFinished directly + // and a cached no-change compile lasts ~110 ms. Every await between here + // and the socket captures Unity's synchronization context; a continuation + // posted to it runs one editor frame later, and there are three of them + // (this method's caller, the CommandRegistry async wrapper, its + // AwaitHandler). Completing the task on the main thread with inlining + // allowed lets the awaiter see the captured context as the current one and + // run all three inline, inside this event handler, so the only hop left + // is the dispatcher's thread-pool send. A poll would also quantise the + // edge to the update tick, which in an unfocused Editor is most of that + // window on its own. + // + // EditorStateCache subscribed to the same event at domain load, so its + // handler has already flipped GetActualIsCompiling() by the time this one + // runs; the caller reads resulting_state = "compiling" inline. + var tcs = new TaskCompletionSource(); + var start = DateTime.UtcNow; + Action onStarted = null; + EditorApplication.CallbackFunction tick = null; + + onStarted = _ => + { + CompilationPipeline.compilationStarted -= onStarted; + EditorApplication.update -= tick; + tcs.TrySetResult(true); + }; + + // The update hook only carries the grace: the pipeline declined or deferred + // the request, so no reload is coming and inlining is harmless there too. + tick = () => + { + if ((DateTime.UtcNow - start) <= grace) + { + return; + } + + CompilationPipeline.compilationStarted -= onStarted; + EditorApplication.update -= tick; + tcs.TrySetResult(false); + }; + + CompilationPipeline.compilationStarted += onStarted; + EditorApplication.update += tick; + // Nudge Unity to pump once in case update is throttled. + try { EditorApplication.QueuePlayerLoopUpdate(); } catch { } + return tcs.Task; + } + private static Task WaitForUnityReadyAsync(TimeSpan timeout) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 6b0e6d1132..bf08d2ce47 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen; @@ -13,7 +16,9 @@ namespace MCPForUnity.Editor.Windows.Components.AssetGen /// Controller for the AI Asset Generation settings tab. This tab is CONFIG ONLY: /// it lets users enter/clear per-provider API keys, toggle providers on/off, /// presence-check a key, and set non-secret generation preferences. - /// Generation itself is never triggered here — only via MCP tools / CLI. + /// Generation itself is never triggered here — only via MCP tools / CLI. The one exception is + /// the Blender Bridge block (): its buttons run local socket + /// and file operations against a Blender on this machine, never a paid provider call. /// /// Keys are written to the OS secure store (), never to /// EditorPrefs or the project. The stored key is never read back into the field; only @@ -23,13 +28,19 @@ public class McpAssetGenSection { // Fixed provider lists. Each Id is both the SecureKeyStore key and the // AssetGenPrefs enable-flag id. All model/marketplace providers below emit GLB. + // fal's key and enable toggle live on its 2D Images row; the 3D row only picks a model. private static readonly (string Id, string Label)[] ModelProviders = { ("tripo", "Tripo"), ("meshy", "Meshy"), + ("fal", "fal (3D)"), ("sketchfab", "Sketchfab"), }; + // Editor dropdowns open a native menu with no search, so long catalogs are capped and + // the per-provider search field reaches the remaining models. + internal const int MenuLimit = 25; + private static readonly (string Id, string Label)[] ImageProviders = { ("fal", "fal"), @@ -44,10 +55,15 @@ private static readonly (string Id, string Label)[] ImageProviders = private Toggle autoNormalizeToggle; private Button refreshButton; private Label refreshStatusLabel; + private McpBlenderBridgePanel blenderPanel; // Per-provider enable toggles for the GLB-capable (model) providers, used to // recompute the glTFast notice when a toggle changes. private readonly List<(string Id, Toggle Toggle)> modelEnableToggles = new(); + private readonly List<(VisualElement Container, string Kind, string Provider)> modelControls = new(); + private readonly Dictionary searches = new(); + // Last failed compatibility check per kind/provider, kept so a catalog rebuild cannot hide it. + private readonly Dictionary verifyErrors = new(); public VisualElement Root { get; private set; } @@ -57,8 +73,30 @@ public McpAssetGenSection(VisualElement root) CacheUIElements(); InitializeUI(); RegisterCallbacks(); + Root.RegisterCallback(_ => + { + SubscribeCatalogs(); + // Catch up on catalog changes that fired while the tab was detached. + RebuildModelControls(null, null); + }); + Root.RegisterCallback(_ => { FalModelCatalog.Changed -= OnFalChanged; OpenRouterModelCatalog.Changed -= OnRouterChanged; }); + if (Root.panel != null) SubscribeCatalogs(); + Root.schedule.Execute(() => { if (!modelControls.Any(c => FalModelCatalog.IsRefreshing(c.Kind)) && !OpenRouterModelCatalog.IsRefreshing) _ = RefreshCatalog(false); }).Every(60000); + _ = RefreshCatalog(false); + } + + private void SubscribeCatalogs() + { + FalModelCatalog.Changed -= OnFalChanged; + FalModelCatalog.Changed += OnFalChanged; + OpenRouterModelCatalog.Changed -= OnRouterChanged; + OpenRouterModelCatalog.Changed += OnRouterChanged; } + private void OnFalChanged(string kind) => RebuildModelControls(kind, "fal"); + + private void OnRouterChanged() => RebuildModelControls("image", "openrouter"); + private void CacheUIElements() { providersContainer = Root.Q("assetgen-providers-container"); @@ -68,6 +106,9 @@ private void CacheUIElements() autoNormalizeToggle = Root.Q("assetgen-auto-normalize"); refreshButton = Root.Q