From a034ab0b21f4826c80cf723b2b8132a5bbe2b3f7 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Mon, 24 Nov 2025 23:21:06 -0400 Subject: [PATCH] HTTP Server, uvx, C# only custom tools (#375) * Remove temp folder from repo * Ignore boot.config * Remove buttons to download or rebuild the server * Remove embedded MCP server in plugin We'll reference the remote server in GitHub and configure clients to use `uvx` * As much as possible, rip out logic that installs a server * feat: migrate to uvx-based server configuration - Replaced local server execution with uvx package-based configuration for improved reliability - Added GetUvxCommand helper to generate correct package version command string - Updated config generation to use `uvx mcp-for-unity` instead of local Python server - Modified Codex and client configuration validation to support uvx-based setup - Removed unused server source directory handling and related preferences - Updated tests to verify uvx command generation * Cleanup the temp folders created by tests We don't commit temp folders, tests are expected to clean up after themselves * The test kept failing but the results looked correct, floating point comparisons are not precise * feat: migrate from local server to uvx-based configuration - Replaced local server path detection with uvx-based package installation from git repository - Updated all configuration generators to use structured uvx command parts (command, --from URL, package) - Renamed UV path references to UVX for clarity and consistency - Added GetUvxCommandParts() helper to centralize uvx command generation - Added GetMcpServerGitUrl() to handle git repository URL construction - Updated client configuration validation * refactor: use dynamic package version instead of hardcoded value * Update CI so it only updates the Server folder * feat: implement uvx package source path resolution - Added GetUvxPackageSourcePath method to locate unity-mcp package in uv cache by traversing git checkouts - Replaced hardcoded "Dummy" path in PythonToolSyncProcessor with dynamic path resolution - Added validation for Server directory structure and pyproject.toml to ensure correct package location * refactor: replace Python tool syncing with custom tool registration system - Removed PythonToolsAsset and file-based sync processor in favor of attribute-based tool discovery - Implemented CustomToolRegistrationProcessor with automatic registration on startup and script reload - Added registration enable/disable preference and force re-registration capability * feat: add HTTP transport support and cache management - Implemented HTTP transport option with configurable URL/port alongside existing stdio mode - Added cache management service with menu item to clear uvx package cache - Updated config builder to generate transport-specific arguments and VSCode type field based on selected mode * refactor: simplify HTTP configuration to use URL-based approach - Replaced separate host/port arguments with single --http-url parameter for cleaner configuration - Updated server to parse URL and allow individual host/port overrides when needed - Consolidated HTTP client implementation with connection testing and tool execution support * refactor: standardize transport configuration with explicit --transport flag - Replaced --enable-http-server flag with --transport choice parameter (stdio/http) for clearer intent - Removed redundant HTTP port field from UI since HTTP mode uses the same URL/port as MCP client - Simplified server startup logic by consolidating transport mode determination * refactor: move MCP menu items under Window menu * feat: restructure config generation for HTTP transport mode - Changed HTTP mode to use URL-based configuration instead of command-line arguments - Added proper cleanup of incompatible fields when switching between stdio and HTTP transports - Moved uvx command parsing inside stdio-specific block to avoid unnecessary processing in HTTP mode * feat: add local HTTP server management with Git URL override - Implemented server management service with menu item to start local HTTP server in new terminal window - Added Git URL override setting in advanced configuration to allow custom server source for uvx --from - Integrated server management into service locator with validation for local-only server startup * fix: remove automatic HTTP protocol prefix from URL field - Removed auto-prefixing logic that added "http://" to URLs without protocol - Added placeholder text to guide users on expected URL format - Created dedicated url-field style class for better URL input styling * feat: implement proper MCP session lifecycle with HTTP transport - Added initialize, ping, and disconnect methods to HttpMcpClient for proper MCP protocol session management - Implemented session ID tracking and header management for stateful HTTP connections - Added cross-platform terminal launcher support for Windows and Linux (previously macOS-only) * feat: implement JSON-RPC protocol for MCP tool execution - Added proper JSON-RPC 2.0 request/response handling with request ID tracking - Included MCP protocol headers (version, session ID) for standard compliance - Added error handling for JSON-RPC error responses * feat: improve text wrapping in editor window UI - Added white-space: normal and flex-shrink properties to section headers and override labels to prevent text overflow - Created new help-text style class for consistent formatting of help text elements * refactor: refresh git URL override from EditorPrefs on validation * fix: improve responsive layout for editor window settings - Added flex-wrap to setting rows to prevent overflow on narrow windows - Set flex-shrink: 0 on labels to maintain consistent width - Replaced max-width and margin-left with flex-basis for better flex behavior * refactor: improve thread safety in tool registration - Capture Unity API calls on main thread before async operations to prevent threading issues - Update RegisterAllTools to use Task.Run pattern instead of GetAwaiter().GetResult() to avoid potential deadlocks - Add optional projectId parameter to RegisterAllToolsAsync for pre-captured values * refactor: replace MCP tool calls with direct HTTP endpoints for tool registration - Removed synchronous registration method and unused MCP bridge logic from CustomToolRegistrationService - Changed tool registration to use direct HTTP POST to /register-tools endpoint instead of MCP protocol - Added FastAPI HTTP routes alongside existing MCP tools for more flexible tool management access * refactor: centralize HTTP endpoint URL management - Created HttpEndpointUtility to normalize and manage base URLs consistently - Replaced scattered EditorPrefs calls with utility methods that handle URL normalization - Ensured base URL storage excludes trailing paths like "/mcp" for cleaner configuration * refactor: simplify custom tools management with in-memory registry - Removed CustomToolsManager and fastmcp_tool_registry modules in favor of inline implementation - Replaced class-based tool management with direct HTTP route handlers using FastMCP's custom_route decorator - Consolidated tool registration logic into simple dictionary-based storage with helper functions * feat: add dynamic custom tool registration system - Implemented CustomToolService to manage project-scoped tool registration with validation and conflict detection - Added HTTP endpoints for registering, listing, and unregistering custom tools with proper error handling - Converted health and registry endpoints from HTTP routes to MCP tools for better integration * feat: add AutoRegister flag to control tool registration - Added AutoRegister property to McpForUnityToolAttribute (defaults to true) - Modified registration service to filter and only register tools with AutoRegister enabled - Disabled auto-registration for all built-in tools that already exist server-side * feat: add function signature generation for dynamic tools - Implemented _build_signature method to create proper inspect.Signature objects for dynamically created tools - Signature includes Context parameter and all tool parameters with correct required/optional defaults - Attached generated signature to dynamic_tool functions to improve introspection and type checking * refactor: remove unused custom tool registry endpoints * test: add transport configuration validation for MCP client tests - Added HTTP transport preference setup in test fixtures to ensure consistent behavior - Implemented AssertTransportConfiguration helper to validate both HTTP and stdio transport modes - Added tests to verify stdio transport fallback when HTTP preference is disabled * refactor: simplify uvx path resolution to use PATH by default - Removed complex platform-specific path detection logic and verification - Changed to rely on system PATH environment variable instead of searching common installation locations - Streamlined override handling to only use EditorPrefs when explicitly set by user * feat: use serverUrl property for Windsurf HTTP transport - Changed Windsurf configs to use "serverUrl" instead of "url" for HTTP transport to match Windsurf's expected format - Added cleanup logic to remove stale transport properties when switching between HTTP and stdio modes - Updated Windsurf to exclude "env" block (only required for Kiro), while preserving it for clients that need it * feat: ensure client configurations stay current on each setup - Removed skip logic for already-configured clients to force re-validation of core fields - Added forced re-registration for ClaudeCode clients to keep transport settings up-to-date * feat: add automatic migration for legacy embedded server configuration - Created LegacyServerSrcMigration to detect and migrate old EditorPrefs keys on startup - Automatically reconfigures all detected clients to use new uvx/stdio path - Removes legacy keys only after successful migration to prevent data loss * feat: add automatic stdio config migration on package updates - Implemented StdIoVersionMigration to detect package version changes and refresh stdio MCP client configurations - Added support for detecting stdio usage across different client types (Codex, VSCode, and generic JSON configs) - Integrated version tracking via EditorPrefs to prevent redundant migrations * Centralize where editor prefs are defined It's really hard to get a view of all the editor prfes in use. This should help humans and AI know what's going on at a glance * Update custom tools docs * refactor: consolidate server management UI into main editor window - Removed server and maintenance menu items from top-level menu - Moved "Start Local HTTP Server" and "Clear UVX Cache" buttons into editor window settings - Added dynamic button state management based on transport protocol and server availability * Don't show error logs when custom tools are already registerd with the server * Only autoconnect to port 6400 if the user is using stdio for connections * Don't double register tools on startup * feat: switch to HTTP transport as default connection method - Changed default transport from stdio to HTTP with server running on localhost:8080 - Added UI controls to start/stop local HTTP server directly from Unity window - Updated all documentation and configuration examples to reflect HTTP-first approach with stdio as fallback option * Automatically bump the versions in the READMEs. The `main` branch gets updated before we do a release. Using versions helps users get a stable, tested installation * docs: add HTTP transport configuration examples - Added HTTP transport setup instructions alongside existing stdio examples - Included port mapping and URL configuration for Docker deployments - Reorganized client configuration sections to clearly distinguish between HTTP and stdio transports * feat: add WebSocket-based plugin hub for Unity connections - Implemented persistent WebSocket connections with session management, heartbeat monitoring, and command routing - Created PluginRegistry for tracking active Unity instances with hash-based lookup and automatic reconnect handling - Added HTTP endpoints for session listing and health checks, plus middleware integration for instance-based routing * refactor: consolidate Unity instance discovery with shared registry - Introduced StdioPortRegistry for centralized caching of Unity instance discovery results - Refactored UnityConnection to use stdio_port_registry instead of direct PortDiscovery calls - Improved error handling with specific exception types and enhanced logging clarity * Use websockets so that local and remote MCP servers can communicate with Unity The MCP server supports HTTP and stdio protocols, and the MCP clients use them to communicate. However, communication from the MCP server to Unity is done on the local port 6400, that's somewhat hardcoded. So we add websockets so oure remotely hosted MCP server has a valid connection to the Unity plugin, and can communicate with - Created ProjectIdentityUtility for centralized project hash, name, and session ID management - Moved command processing logic from MCPForUnityBridge to new TransportCommandDispatcher service - Added WebSocket session ID and URL override constants to EditorPrefKeys - Simplified command queue processing with async/await pattern and timeout handling - Removed duplicate command execution code in favor of shared dispatcher implementation * refactor: simplify port management and improve port field validation - Removed automatic port discovery and fallback logic from GetPortWithFallback() - Changed GetPortWithFallback() to return stored port or default without availability checks - Added SetPreferredPort() method for explicit port persistence with validation - Replaced Debug.Log calls with McpLog.Info/Warn for consistent logging - Added port field validation on blur and Enter key press with error handling - Removed automatic port waiting * Launch the actual local webserver via the button * Autoformat * Minor fixes so the server can start * Make clear uvx button work * Don't show a dialog after clearing cache/starting server successfully It's annoying, we can just log when successful, and popup if something failed * We no longer need a Python importer * This folder has nothing in it * Cleanup whitespace Most AI generated code contains extra space, unless they're hooked up to a linter. So I'm just cleaning up what's there * We no longer need this folder * refactor: move MCPForUnityBridge to StdioBridgeHost and reorganize transport layer - Renamed MCPForUnityBridge class to StdioBridgeHost and moved to Services.Transport.Transports namespace - Updated all references to StdioBridgeHost throughout codebase (BridgeControlService, TelemetryHelper, GitHub workflow) - Changed telemetry bridge_version to use AssetPathUtility.GetPackageVersion() instead of hardcoded version - Removed extensive inline comments and documentation throughout StdioBridgeHost * Skip tools registration if the user is not connected to an HTTP server * Fix VS Code configured status in UI Serializing the config as dynamic and then reading null properties (in this case, args) caused the error. So we just walk through the properities and use JObject, handling null value explicitily * Stop blocking the main thread when connecting via HTTP Now that the bridge service is asynchronous, messages back and forth the server work well (including the websocket connection) * Separate socket keep-alive interval from application keep-alive interval Split the keep-alive configuration into two distinct intervals: _keepAliveInterval for application-level keep-alive and _socketKeepAliveInterval for WebSocket-level keep-alive. This allows independent control of socket timeout behavior based on server configuration while maintaining the application's keep-alive settings. * Add a debug log line * Fix McpLog.Debug method, so it actually reads the checkbox value from the user * Add HTTP bridge auto-resume after domain reload Implement HttpBridgeReloadHandler to automatically resume HTTP/HttpPush transports after Unity domain reloads, matching the behavior of the legacy stdio bridge. Add ResumeHttpAfterReload EditorPref key to persist state across reloads and expose ActiveMode property in IBridgeControlService to check current transport mode. * Add health verification after HTTP bridge auto-resume Trigger health check in all open MCPForUnityEditorWindow instances after successful HTTP bridge resume following domain reload. Track open windows using static HashSet and schedule async health verification via EditorApplication.delayCall to ensure UI updates reflect the restored connection state. * Add name and path fields to code coverage settings Initialize m_Name and m_Path fields in code coverage Settings.json to match Unity's expected settings file structure. * Only register custom tools AFTER we established a healthy HTTP connection * Convert custom tool handlers to async functions Update dynamic_tool wrapper to use async/await pattern and replace synchronous send_with_unity_instance/send_command_with_retry calls with their async counterparts (async_send_with_unity_instance/async_send_command_with_retry). * Correctly parse responses from Unity in the server so tools and resources can process them We also move the logic to better places than the __init__.py file for tools, since they're shared across many files, including resources * Make some clarifications for custom tools in docs * Use `async_send_with_unity_instance` instead of `send_with_unity_instance` The HTTP protocol doesn't working with blocking commands, so now we have our tools set up to work with HTTP and stdio fullly. It's coming together :-) * Fix calls to async_send_with_unity_instance in manage_script * Rename async_send_with_unity_instance to send_with_unity_instance * Fix clear uv cache command Helps a lot with local development * Refactor HTTP server command generation into reusable method and display in UI Extract HTTP server command building logic from StartLocalHttpServer into new TryGetLocalHttpServerCommand method. Add collapsible foldout in editor window to display the generated command with copy button, allowing users to manually start the server if preferred. Update UI state management to refresh command display when transport or URL settings change. * Ctrl/Cmd + Shift + M now toggles the window Might as well be able to close the window as well * Fallback to a git URL that points to the main branch for the MCP git URL used by uvx * Add test setup/teardown to preserve and reset Git URL override EditorPref Implement OneTimeSetUp/OneTimeTearDown to save and restore the GitUrlOverride EditorPref state, and add SetUp to delete the key before each test. This ensures tests run with deterministic Git URLs while preserving developer overrides between test runs. * Update docs, scripts and GH workflows to use the new MCP server code location * Update plugin README * Convert integration tests to async/await pattern Update all integration tests to use pytest.mark.asyncio decorator and async/await syntax. Change test functions to async, update fake_send/fake_read mocks to async functions with **kwargs parameter, and patch async_send_command_with_retry instead of send_command_with_retry. Add await to all tool function calls that now return coroutines. * Update image with new UI * Remove unused HttpTransportClient client Before I had the realization that I needed webscokets, this was my first attempt * Remove copyright notice * Add a guide to all the changes made for this version A lot of code was written by AI, so I think it's important that humans can step through how all these new systems work, and know where to find things. All of these docs were written by hand, as a way to vet that I understand what the code I wrote and generated are doing, but also to make ti easy to read for you. * Organize imports and remove redundant import statements Clean up import organization by moving imports to the top of the file, removing duplicate imports scattered throughout the code, and sorting imports alphabetically within their groups (standard library, third-party, local). Remove unnecessary import aliases and consolidate duplicate urlparse and time imports. * Minor edits * Fix stdio serializer to use the new type parameter like HTTP * Fix: Automatic bridge reconnection after domain reload without requiring Unity focus - Add immediate restart attempt in OnAfterAssemblyReload() when Unity is not compiling - Enhanced compile detection to check both EditorApplication.isCompiling and CompilationPipeline.isCompiling - Add brief port release wait in StdioBridgeHost before switching ports to reduce port thrash - Fallback to delayCall/update loop only when Unity is actively compiling This fixes the issue where domain reloads (e.g., script edits) would cause connection loss until Unity window was refocused, as EditorApplication.update only fires when Unity has focus. * Make the server work in Docker We use HTTP mode by default in docker, this is what will be hosted remotely if one chooses to. We needed to update the uvicorn package to a version with websockets, at least so the right version is explicitly retrieved * Cache project identity on initialization to avoid repeated computation Add static constructor with [InitializeOnLoad] attribute to cache project hash and name at startup. Introduce volatile _identityCached flag and cached values (_cachedProjectName, _cachedProjectHash) to store computed identity. Schedule cache refresh on initialization and when project changes via EditorApplication.projectChanged event. Extract ComputeProjectHash and ComputeProjectName as private methods that perform the actual computation. Update public * Fix typos * Add unity_instance_middleware to py-modules list in pyproject.toml * Remove Foldout UI elements and simplify HTTP server command section Replace Foldout with VisualElement for http-server-command-section to display HTTP server command directly without collapsible wrapper. Remove unused manualConfigFoldout field and associated CSS styles. Remove unused _identityCached volatile flag from ProjectIdentityUtility as caching logic no longer requires it. * Reduce height of HTTP command box * Refresh HTTP server command display when Git URL override changes * Make the box a bit smaller * Split up main window into various components Trying to avoid to monolithic files, this is easier to work, for humans and LLMs * Update the setup wizard to be a simple setup popup built with UI toolkit We also fix the Python/uv detectors. Instead of searching for binaries, we just test that they're available in the PATH * Ensure that MCP configs are updated when users switch between HTTP and stdio These only work for JSON configs, we'll have to handle Codex and Claude Code separately * Detect Codex configuration when using HTTP or stdio configs * Use Claude Code's list command to detect whether this MCP is configured It's better than checking the JSON and it can verify both HTTP and stdio setups * Fix and add tests for building configs * Handle Unity reload gaps by retrying plugin session resolution * Add polling support for long-running tools with state persistence Introduce polling middleware to handle long-running operations that may span domain reloads. Add McpJobStateStore utility to persist tool state in Library folder across reloads. Extend McpForUnityToolAttribute with RequiresPolling and PollAction properties. Update Response helper with Pending method for standardized polling responses. Implement Python-side polling logic in custom_tool_service.py with configurable intervals and 10-minute timeout. * Polish domain reload resilience tests and docs * Refactor Response helper to use strongly-typed classes instead of anonymous objects Replace static Response.Success/Error/Pending methods with SuccessResponse, ErrorResponse, and PendingResponse classes. Add IMcpResponse interface for type safety. Include JsonProperty attributes for serialization and JsonIgnore properties for backward compatibility with reflection-based tests. Update all tool and resource classes to use new response types. * Rename Setup Wizard to Setup Window and improve UV detection on macOS/Linux Rename SetupWizard class to SetupWindowService and update all references throughout the codebase. Implement platform-specific UV detection for macOS and Linux with augmented PATH support, including TryValidateUv methods and BuildAugmentedPath helpers. Split single "Open Installation Links" button into separate Python and UV install buttons. Update UI styling to improve installation section layout with proper containers and button * Update guide on what's changed in v8 Lots of feedback, lots of changes * Update custom tool docs to use new response objects * Update image used in README Slightly more up to date but not final * Restructure backend Just make it more organized, like typical Python projects * Remove server_version.txt * Feature/http instance routing (#5) * Fix HTTP instance routing and per-project session IDs * Drop confusing log message * Ensure lock file references later version of uvicorn with key fixes * Fix test imports * Update refs in docs --------- Co-authored-by: David Sarno * Generate the session ID from the server We also make the identifying hashes longer * Force LLMs to choose a Unity instance when multiple are connected OK, this is outright the best OSS Unity MCP available * Fix tests caused by changes in session management * Whitespace update * Exclude stale builds so users always get the latest version * Set Pythonpath env var so Python looks at the src folder for modules Not required for the fix, but it's a good guarantee regardless of the working directory * Replace Optional type hints with modern union syntax (Type | None) Update all Optional[Type] annotations to use the PEP 604 union syntax Type | None throughout the transport layer and mcp_source.py script * Replace Dict type hints with modern dict syntax throughout codebase Update all Dict[K, V] annotations to use the built-in dict[K, V] syntax across services, transport layer, and models for consistency with PEP 585 * Remove unused type imports across codebase Clean up unused imports of Dict, List, and Path types that are no longer needed after migration to modern type hint syntax * Remove the old telemetry test It's working, we have a better integration test in any case * Clean up stupid imports No AI slop here lol * Replace dict-based session data with Pydantic models for type safety Introduce Pydantic models for all WebSocket messages and session data structures. Replace dict.get() calls with direct attribute access throughout the codebase. Add validation and error handling for incoming messages in PluginHub. * Correctly call `ctx.info` with `await` No AI slop here! * Replace printf-style logging with f-string formatting across transport and telemetry modules Convert all logger calls using %-style string formatting to use f-strings for consistency with modern Python practices. Update telemetry configuration logging, port discovery debug messages, and Unity connection logging throughout the codebase. * Register custom tools via websockets Since we'll end up using websockets for HTTP and stdio, this will ensure custom tools are available to both. We want to compartmentalize the custom tools to the session. Custom tools in 1 unity project don't apply to another one. To work with our multi-instance logic, we hide the custom tools behind a custom tool function tool. This is the execute_custom_tool function. The downside is that the LLM has to query before using it. The upside is that the execute_custom_tool function goes through the standard routing in plugin_hub, so custom tools are always isolated by project. * Add logging decorator to track tool and resource execution with arguments and return values Create a new logging_decorator module that wraps both sync and async functions to log their inputs, outputs, and exceptions. Apply this decorator to all tools and resources before the telemetry decorator to provide detailed execution traces for debugging. * Fix JSONResponse serialization by converting Pydantic model to dict in plugin sessions endpoint * Whitespace * Move import of get_unity_instance_from_context to module level in unity_transport Relocate the import from inside the with_unity_instance decorator function to the top of the file with other imports for better code organization and to avoid repeated imports on each decorator call. * Remove the tool that reads resources They don't perform well at all, and confuses the models most times. However, if they're required, we'll revert * We have buttons for starting and stopping local servers Instead of a button to clear uv cache, we have start and stop buttons. The start button pulls the latest version of the server as well. The stop button finds the local process of the server and kills. Need to test on Windows but it works well * Consolidate cache management into ServerManagementService and remove standalone CacheManagementService Move the ClearUvxCache method from CacheManagementService into ServerManagementService since cache clearing is primarily used during server operations. Remove the separate ICacheManagementService interface and CacheManagementService class along with their service locator registration. Update StartLocalServer to call the local ClearUvxCache method instead of going through the service locator. * Update MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update .github/workflows/claude-nl-suite.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Cancel existing background loops before starting a new connection Nice bug found from CodeRabbit * Try to kill all processes using the port of the local webserver * Some better error handling when stopping a server * Cache fallback session ID to maintain consistency when EditorPrefs are unavailable Store the fallback session ID in a static field instead of generating a new GUID on each call when EditorPrefs are unavailable during batch tests. Clear the cached fallback ID when resetting the session to ensure a fresh ID is generated on the next session. * Clean up empty parent temp folder after domain reload tests complete Check if Assets/Temp folder is empty after deleting test-specific temp directories and remove it if no other files or directories remain. Also remove trailing blank lines from the file. * Minor fixes * Change "UV" to "uv" in strings. Capitlization looks weird * Rename functions that capitalized "UV" * Ensure WebSocket transport is properly stopped before disposing shared resources Add disposal guard and call StopAsync() in Dispose() to prevent race conditions when disposing the WebSocket transport while background loops are still running. Log warnings if cleanup fails but continue with resource disposal. * Replace volatile bool with Interlocked operations for reconnection flag to prevent race conditions * Replace byte array allocation with ArrayPool to reduce GC pressure in WebSocket message receiving Rent buffer from ArrayPool.Shared instead of allocating new byte arrays for each receive operation. Pre-size MemoryStream to 8192 bytes and ensure rented buffer is returned in finally block to prevent memory leaks. * Consolidate some of the update/refresh logic * UI tweak disable start/stop buttons while they code is being fired * Add error dialog when Unity socket port persistence fails * Rename WebSocketSessionId to SessionId in EditorPrefKeys By the next version stdio will use Websockets as well, so why be redundant * No need to send session ID in pong payload * Add a debug message when we don't have an override for the uvx path * Remove unused function * Remove the unused verifyPath argument * Simplify server management logic * Remove unused `GetUvxCommand()` function We construct it in parts now * Remove `IsUvxDetected()` The flow changed so it checks editor prefs and then defaults to the command line default. So it's always true. * Add input validation and improve shell escaping in CreateTerminalProcessStartInfo - Validate command is not empty before processing - Strip carriage returns and newlines from command - macOS: Use osascript directly instead of bash to avoid shell injection, escape backslashes and quotes for AppleScript - Windows: Add window title and escape quotes in command - Linux: Properly escape single quotes for bash -c and double quotes for process arguments * Update technical changes guide * Add custom_tools resource and execute_custom_tool to README documentation * Update v8 docs * Update docs UI image * Handle when properties are sent as a JSON string in manage_asset * Fix backend tests --------- Co-authored-by: David Sarno Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .dockerignore | 6 + .github/workflows/bump-version.yml | 18 +- .github/workflows/claude-nl-suite.yml | 19 +- .github/workflows/python-tests.yml | 10 +- .gitignore | 2 - .../Editor/{Importers.meta => Constants.meta} | 2 +- .../Editor/Constants/EditorPrefKeys.cs | 40 + .../EditorPrefKeys.cs.meta} | 2 +- MCPForUnity/Editor/Data/PythonToolsAsset.cs | 107 -- .../Editor/Dependencies/DependencyManager.cs | 16 +- .../PlatformDetectors/IPlatformDetector.cs | 13 +- .../LinuxPlatformDetector.cs | 146 +- .../MacOSPlatformDetector.cs | 152 +- .../PlatformDetectors/PlatformDetectorBase.cs | 134 +- .../WindowsPlatformDetector.cs | 64 +- .../Editor/Helpers/AssetPathUtility.cs | 48 +- .../Editor/Helpers/CodexConfigHelper.cs | 136 +- .../Editor/Helpers/ConfigJsonBuilder.cs | 130 +- MCPForUnity/Editor/Helpers/ExecPath.cs | 9 +- .../Editor/Helpers/GameObjectSerializer.cs | 30 +- .../Editor/Helpers/HttpEndpointUtility.cs | 86 + .../HttpEndpointUtility.cs.meta} | 2 +- .../Editor/Helpers/McpConfigurationHelper.cs | 182 +- .../Editor/Helpers/McpJobStateStore.cs | 62 + ...essor.cs.meta => McpJobStateStore.cs.meta} | 2 +- MCPForUnity/Editor/Helpers/McpLog.cs | 32 +- MCPForUnity/Editor/Helpers/McpPathResolver.cs | 123 -- .../Editor/Helpers/PackageLifecycleManager.cs | 240 --- MCPForUnity/Editor/Helpers/PortManager.cs | 67 +- .../Editor/Helpers/ProjectIdentityUtility.cs | 260 +++ ...cs.meta => ProjectIdentityUtility.cs.meta} | 2 +- .../Editor/Helpers/PythonToolSyncProcessor.cs | 188 -- MCPForUnity/Editor/Helpers/Response.cs | 154 +- MCPForUnity/Editor/Helpers/ServerInstaller.cs | 1001 ----------- .../Editor/Helpers/ServerInstaller.cs.meta | 11 - .../Editor/Helpers/ServerPathResolver.cs | 141 -- .../Editor/Helpers/ServerPathResolver.cs.meta | 11 - MCPForUnity/Editor/Helpers/TelemetryHelper.cs | 12 +- .../Editor/Importers/PythonFileImporter.cs | 21 - .../Importers/PythonFileImporter.cs.meta | 11 - MCPForUnity/Editor/MCPForUnity.Editor.asmdef | 9 +- MCPForUnity/Editor/MCPForUnityBridge.cs.meta | 11 - MCPForUnity/Editor/MCPForUnityMenu.cs | 75 - .../Editor/MenuItems.meta | 2 +- .../Editor/MenuItems/MCPForUnityMenu.cs | 46 + .../{ => MenuItems}/MCPForUnityMenu.cs.meta | 0 .../Editor/Migrations.meta | 2 +- .../Migrations/LegacyServerSrcMigration.cs | 71 + .../LegacyServerSrcMigration.cs.meta | 11 + .../Migrations/StdIoVersionMigration.cs | 155 ++ .../Migrations/StdIoVersionMigration.cs.meta | 11 + .../Editor/Resources/Editor/ActiveTool.cs | 4 +- .../Editor/Resources/Editor/EditorState.cs | 6 +- .../Editor/Resources/Editor/PrefabStage.cs | 8 +- .../Editor/Resources/Editor/Selection.cs | 4 +- .../Editor/Resources/Editor/Windows.cs | 4 +- .../Resources/MenuItems/GetMenuItems.cs | 2 +- .../Editor/Resources/Project/Layers.cs | 6 +- .../Editor/Resources/Project/ProjectInfo.cs | 8 +- MCPForUnity/Editor/Resources/Project/Tags.cs | 4 +- .../Editor/Resources/Tests/GetTests.cs | 12 +- .../Editor/Services/BridgeControlService.cs | 250 ++- .../Services/ClientConfigurationService.cs | 268 +-- .../Services/HttpBridgeReloadHandler.cs | 143 ++ .../Services/HttpBridgeReloadHandler.cs.meta | 11 + .../Editor/Services/IBridgeControlService.cs | 42 +- .../Services/IClientConfigurationService.cs | 24 +- .../Editor/Services/IPackageUpdateService.cs | 14 +- .../Editor/Services/IPathResolverService.cs | 64 +- .../Services/IPythonToolRegistryService.cs | 14 - .../IPythonToolRegistryService.cs.meta | 11 - .../Services/IServerManagementService.cs | 46 + .../Services/IServerManagementService.cs.meta | 11 + .../Editor/Services/IToolDiscoveryService.cs | 53 + .../Services/IToolDiscoveryService.cs.meta | 11 + .../Editor/Services/IToolSyncService.cs | 18 - .../Editor/Services/IToolSyncService.cs.meta | 11 - .../Editor/Services/MCPServiceLocator.cs | 33 +- .../Editor/Services/PackageUpdateService.cs | 5 +- .../Editor/Services/PathResolverService.cs | 255 +-- .../Services/PythonToolRegistryService.cs | 55 - .../PythonToolRegistryService.cs.meta | 11 - .../Services/ServerManagementService.cs | 496 ++++++ .../Services/ServerManagementService.cs.meta | 11 + .../Editor/Services/ToolDiscoveryService.cs | 184 ++ .../Services/ToolDiscoveryService.cs.meta | 11 + .../Editor/Services/ToolSyncService.cs | 134 -- .../Editor/Services/ToolSyncService.cs.meta | 11 - .../Editor/Services/Transport.meta | 2 +- .../Services/Transport/IMcpTransportClient.cs | 18 + .../Transport/IMcpTransportClient.cs.meta | 11 + .../Transport/TransportCommandDispatcher.cs | 314 ++++ .../TransportCommandDispatcher.cs.meta | 11 + .../Services/Transport/TransportManager.cs | 106 ++ .../Transport/TransportManager.cs.meta | 11 + .../Services/Transport/TransportState.cs | 52 + .../Services/Transport/TransportState.cs.meta | 11 + .../Editor/Services/Transport/Transports.meta | 8 + .../Transport/Transports/StdioBridgeHost.cs} | 546 +++--- .../Transports/StdioBridgeHost.cs.meta | 11 + .../Transports/StdioTransportClient.cs | 50 + .../Transports/StdioTransportClient.cs.meta | 11 + .../Transports/WebSocketTransportClient.cs | 690 ++++++++ .../WebSocketTransportClient.cs.meta | 11 + .../{SetupWizard.cs => SetupWindowService.cs} | 31 +- ...ard.cs.meta => SetupWindowService.cs.meta} | 0 MCPForUnity/Editor/Setup/SetupWizardWindow.cs | 344 ---- .../Editor/Setup/SetupWizardWindow.cs.meta | 11 - MCPForUnity/Editor/Tools/ExecuteMenuItem.cs | 12 +- MCPForUnity/Editor/Tools/ManageAsset.cs | 334 ++-- MCPForUnity/Editor/Tools/ManageEditor.cs | 88 +- MCPForUnity/Editor/Tools/ManageGameObject.cs | 154 +- MCPForUnity/Editor/Tools/ManageScene.cs | 64 +- MCPForUnity/Editor/Tools/ManageScript.cs | 197 +-- MCPForUnity/Editor/Tools/ManageShader.cs | 44 +- .../Editor/Tools/McpForUnityToolAttribute.cs | 84 +- .../Editor/Tools/Prefabs/ManagePrefabs.cs | 44 +- MCPForUnity/Editor/Tools/ReadConsole.cs | 16 +- MCPForUnity/Editor/Tools/RunTests.cs | 10 +- MCPForUnity/Editor/Windows/Components.meta | 8 + .../Windows/Components/ClientConfig.meta | 8 + .../ClientConfig/McpClientConfigSection.cs | 321 ++++ .../McpClientConfigSection.cs.meta | 11 + .../ClientConfig/McpClientConfigSection.uxml | 42 + .../McpClientConfigSection.uxml.meta | 10 + .../Editor/Windows/Components/Common.uss | 437 +++++ .../Editor/Windows/Components/Common.uss.meta | 11 + .../Editor/Windows/Components/Connection.meta | 8 + .../Connection/McpConnectionSection.cs | 449 +++++ .../Connection/McpConnectionSection.cs.meta | 11 + .../Connection/McpConnectionSection.uxml | 47 + .../Connection/McpConnectionSection.uxml.meta | 10 + .../Editor/Windows/Components/Settings.meta | 8 + .../Components/Settings/McpSettingsSection.cs | 229 +++ .../Settings/McpSettingsSection.cs.meta | 11 + .../Settings/McpSettingsSection.uxml | 47 + .../Settings/McpSettingsSection.uxml.meta | 10 + .../Editor/Windows/MCPForUnityEditorWindow.cs | 1044 ++--------- .../Windows/MCPForUnityEditorWindow.uss | 405 +---- .../Windows/MCPForUnityEditorWindow.uxml | 141 +- MCPForUnity/Editor/Windows/MCPSetupWindow.cs | 170 ++ .../Editor/Windows/MCPSetupWindow.cs.meta | 11 + MCPForUnity/Editor/Windows/MCPSetupWindow.uss | 106 ++ .../Editor/Windows/MCPSetupWindow.uss.meta | 11 + .../Editor/Windows/MCPSetupWindow.uxml | 58 + .../Editor/Windows/MCPSetupWindow.uxml.meta | 10 + MCPForUnity/README.md | 16 +- MCPForUnity/UnityMcpServer~/src/Dockerfile | 15 - MCPForUnity/UnityMcpServer~/src/config.py | 47 - MCPForUnity/UnityMcpServer~/src/models.py | 35 - .../UnityMcpServer~/src/pyproject.toml | 41 - .../UnityMcpServer~/src/pyrightconfig.json | 11 - .../UnityMcpServer~/src/resources/__init__.py | 74 - .../src/resources/project_info.py | 38 - .../UnityMcpServer~/src/resources/tests.py | 54 - .../src/resources/unity_instances.py | 67 - MCPForUnity/UnityMcpServer~/src/server.py | 260 --- .../UnityMcpServer~/src/server_version.txt | 1 - MCPForUnity/UnityMcpServer~/src/telemetry.py | 513 ------ .../src/telemetry_decorator.py | 164 -- .../UnityMcpServer~/src/test_telemetry.py | 161 -- .../src/tests/integration/__init__.py | 1 - .../src/tests/integration/conftest.py | 66 - .../test_edit_normalization_and_noop.py | 128 -- .../test_edit_strict_and_warnings.py | 65 - .../integration/test_find_in_file_minimal.py | 51 - .../src/tests/integration/test_get_sha.py | 53 - .../src/tests/integration/test_helpers.py | 55 - .../test_improved_anchor_matching.py | 148 -- .../test_instance_routing_comprehensive.py | 336 ---- .../test_instance_targeting_resolution.py | 80 - .../integration/test_json_parsing_simple.py | 112 -- .../tests/integration/test_logging_stdout.py | 70 - .../test_manage_asset_json_parsing.py | 136 -- .../test_manage_asset_param_coercion.py | 34 - .../test_manage_gameobject_param_coercion.py | 31 - .../integration/test_manage_script_uri.py | 109 -- .../integration/test_read_console_truncate.py | 78 - .../integration/test_read_resource_minimal.py | 57 - .../tests/integration/test_resources_api.py | 75 - .../tests/integration/test_script_editing.py | 36 - .../tests/integration/test_script_tools.py | 179 -- .../test_telemetry_endpoint_validation.py | 61 - .../test_telemetry_queue_worker.py | 52 - .../integration/test_telemetry_subaction.py | 114 -- .../integration/test_transport_framing.py | 222 --- .../test_validate_script_summary.py | 53 - .../UnityMcpServer~/src/tests/pytest.ini | 8 - .../UnityMcpServer~/src/tools/__init__.py | 173 -- .../UnityMcpServer~/src/tools/manage_asset.py | 95 - .../src/tools/manage_gameobject.py | 211 --- .../src/tools/manage_prefabs.py | 57 - .../src/tools/manage_script.py | 595 ------- .../src/tools/manage_shader.py | 63 - .../UnityMcpServer~/src/tools/read_console.py | 104 -- .../src/tools/resource_tools.py | 415 ----- .../UnityMcpServer~/src/tools/run_tests.py | 77 - .../src/tools/script_apply_edits.py | 990 ----------- .../src/tools/set_active_instance.py | 45 - MCPForUnity/UnityMcpServer~/src/uv.lock | 1540 ----------------- README-zh.md | 250 ++- README.md | 131 +- Server/Dockerfile | 20 +- Server/README.md | 74 +- Server/module_discovery.py | 55 - Server/port_discovery.py | 307 ---- Server/pyproject.toml | 19 +- Server/registry/__init__.py | 22 - Server/registry/resource_registry.py | 53 - Server/registry/tool_registry.py | 51 - Server/reload_sentinel.py | 9 - Server/resources/active_tool.py | 46 - Server/resources/editor_state.py | 41 - Server/resources/layers.py | 28 - Server/resources/menu_items.py | 33 - Server/resources/prefab_stage.py | 38 - Server/resources/selection.py | 54 - Server/resources/tags.py | 29 - Server/resources/unity_instances.py | 67 - Server/resources/windows.py | 46 - Server/server.py | 260 --- Server/server_version.txt | 1 - .../src/__init__.py | 0 .../src/tests => Server/src/core}/__init__.py | 0 Server/{ => src/core}/config.py | 15 +- Server/src/core/logging_decorator.py | 37 + Server/{ => src/core}/telemetry.py | 16 +- Server/{ => src/core}/telemetry_decorator.py | 2 +- Server/src/main.py | 405 +++++ Server/src/models/__init__.py | 4 + Server/{ => src/models}/models.py | 23 +- Server/src/models/unity_response.py | 47 + Server/src/routes/__init__.py | 0 Server/src/services/__init__.py | 0 Server/src/services/custom_tool_service.py | 326 ++++ .../src/services}/registry/__init__.py | 0 .../services}/registry/resource_registry.py | 0 .../src/services}/registry/tool_registry.py | 0 .../{ => src/services}/resources/__init__.py | 23 +- .../src/services}/resources/active_tool.py | 9 +- Server/src/services/resources/custom_tools.py | 57 + .../src/services}/resources/editor_state.py | 9 +- .../src/services}/resources/layers.py | 9 +- .../src/services}/resources/menu_items.py | 9 +- .../src/services}/resources/prefab_stage.py | 9 +- .../services}/resources/project_info.py | 9 +- .../src/services}/resources/selection.py | 9 +- .../src/services}/resources/tags.py | 9 +- Server/{ => src/services}/resources/tests.py | 11 +- .../src/services/resources/unity_instances.py | 121 ++ .../src/services}/resources/windows.py | 9 +- Server/src/services/tools/__init__.py | 76 + .../services}/tools/debug_request_context.py | 6 +- .../src/services/tools/execute_custom_tool.py | 38 + .../src/services}/tools/execute_menu_item.py | 9 +- .../{ => src/services}/tools/manage_asset.py | 66 +- .../src/services}/tools/manage_editor.py | 13 +- .../services}/tools/manage_gameobject.py | 25 +- .../services}/tools/manage_prefabs.py | 14 +- .../src/services}/tools/manage_scene.py | 14 +- .../{ => src/services}/tools/manage_script.py | 73 +- .../{ => src/services}/tools/manage_shader.py | 11 +- .../{ => src/services}/tools/read_console.py | 18 +- Server/{ => src/services}/tools/run_tests.py | 9 +- .../services}/tools/script_apply_edits.py | 37 +- .../src/services/tools/set_active_instance.py | 67 + Server/src/transport/__init__.py | 0 .../src/transport/legacy}/port_discovery.py | 55 +- .../transport/legacy/stdio_port_registry.py | 65 + .../src/transport/legacy}/unity_connection.py | 228 ++- Server/src/transport/models.py | 62 + Server/src/transport/plugin_hub.py | 412 +++++ Server/src/transport/plugin_registry.py | 123 ++ .../transport}/unity_instance_middleware.py | 39 +- Server/src/transport/unity_transport.py | 98 ++ .../src/utils}/module_discovery.py | 0 .../src/utils}/reload_sentinel.py | 0 Server/test_telemetry.py | 161 -- Server/tests/integration/conftest.py | 28 +- .../test_domain_reload_resilience.py | 223 +++ .../test_edit_normalization_and_noop.py | 110 +- .../test_edit_strict_and_warnings.py | 49 +- .../integration/test_find_in_file_minimal.py | 47 +- Server/tests/integration/test_get_sha.py | 22 +- Server/tests/integration/test_helpers.py | 10 +- .../test_improved_anchor_matching.py | 9 +- .../test_instance_routing_comprehensive.py | 154 +- .../test_instance_targeting_resolution.py | 36 +- .../integration/test_json_parsing_simple.py | 41 +- .../tests/integration/test_logging_stdout.py | 3 +- .../test_manage_asset_json_parsing.py | 78 +- .../test_manage_asset_param_coercion.py | 11 +- .../test_manage_gameobject_param_coercion.py | 19 +- .../integration/test_manage_script_uri.py | 58 +- .../integration/test_read_console_truncate.py | 38 +- .../integration/test_read_resource_minimal.py | 53 +- .../tests/integration/test_resources_api.py | 72 +- Server/tests/integration/test_script_tools.py | 122 +- .../test_telemetry_endpoint_validation.py | 9 +- .../test_telemetry_queue_worker.py | 2 +- .../integration/test_telemetry_subaction.py | 12 +- .../integration/test_transport_framing.py | 5 +- .../test_validate_script_summary.py | 19 +- Server/tools/__init__.py | 173 -- Server/tools/debug_request_context.py | 61 - Server/tools/execute_menu_item.py | 28 - Server/tools/manage_editor.py | 76 - Server/tools/manage_scene.py | 58 - Server/tools/resource_tools.py | 415 ----- Server/tools/set_active_instance.py | 45 - Server/unity_connection.py | 745 -------- Server/unity_instance_middleware.py | 85 - Server/uv.lock | 34 +- TestProjects/UnityMCPTests/.gitignore | 1 + .../Temp/MaterialDirectPropertiesTests.meta | 8 - .../Temp/MaterialParameterToolTests.meta | 8 - .../Assets/Tests/EditMode/Data.meta | 8 - .../EditMode/Data/PythonToolsAssetTests.cs | 180 -- .../Data/PythonToolsAssetTests.cs.meta | 11 - .../Helpers/CodexConfigHelperTests.cs | 326 +++- .../Helpers/PackageLifecycleManagerTests.cs | 166 -- .../PackageLifecycleManagerTests.cs.meta | 11 - .../EditMode/Helpers/WriteToConfigTests.cs | 190 +- .../Tests/EditMode/MCPToolParameterTests.cs | 846 ++++----- .../Services/PackageUpdateServiceTests.cs | 5 +- .../PythonToolRegistryServiceTests.cs | 135 -- .../PythonToolRegistryServiceTests.cs.meta | 11 - .../EditMode/Services/ToolSyncServiceTests.cs | 72 - .../Services/ToolSyncServiceTests.cs.meta | 11 - .../Tools/DomainReloadResilienceTests.cs | 277 +++ .../Tools/DomainReloadResilienceTests.cs.meta | 11 + .../EditMode/Tools/ManagePrefabsTests.cs | 19 +- .../Tools/MaterialDirectPropertiesTests.cs | 18 + .../Tools/MaterialParameterToolTests.cs | 18 + .../Settings.json | 2 + deploy-dev.bat | 2 +- docker-compose.yml | 13 + docs/CUSTOM_TOOLS.md | 446 ++--- docs/README-DEV-zh.md | 10 +- docs/README-DEV.md | 8 +- docs/TELEMETRY.md | 10 +- docs/images/networking-architecture.png | Bin 0 -> 6951 bytes docs/images/readme_ui.png | Bin 630654 -> 709455 bytes docs/v8_NEW_NETWORKING_SETUP.md | 260 +++ mcp_source.py | 5 +- restore-dev.bat | 2 +- 346 files changed, 12535 insertions(+), 19568 deletions(-) create mode 100644 .dockerignore rename MCPForUnity/Editor/{Importers.meta => Constants.meta} (77%) create mode 100644 MCPForUnity/Editor/Constants/EditorPrefKeys.cs rename MCPForUnity/Editor/{Helpers/PackageLifecycleManager.cs.meta => Constants/EditorPrefKeys.cs.meta} (83%) delete mode 100644 MCPForUnity/Editor/Data/PythonToolsAsset.cs create mode 100644 MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs rename MCPForUnity/Editor/{Data/PythonToolsAsset.cs.meta => Helpers/HttpEndpointUtility.cs.meta} (83%) create mode 100644 MCPForUnity/Editor/Helpers/McpJobStateStore.cs rename MCPForUnity/Editor/Helpers/{PythonToolSyncProcessor.cs.meta => McpJobStateStore.cs.meta} (83%) delete mode 100644 MCPForUnity/Editor/Helpers/McpPathResolver.cs delete mode 100644 MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs create mode 100644 MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs rename MCPForUnity/Editor/Helpers/{McpPathResolver.cs.meta => ProjectIdentityUtility.cs.meta} (83%) delete mode 100644 MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs delete mode 100644 MCPForUnity/Editor/Helpers/ServerInstaller.cs delete mode 100644 MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta delete mode 100644 MCPForUnity/Editor/Helpers/ServerPathResolver.cs delete mode 100644 MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta delete mode 100644 MCPForUnity/Editor/Importers/PythonFileImporter.cs delete mode 100644 MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta delete mode 100644 MCPForUnity/Editor/MCPForUnityBridge.cs.meta delete mode 100644 MCPForUnity/Editor/MCPForUnityMenu.cs rename TestProjects/UnityMCPTests/Assets/Temp.meta => MCPForUnity/Editor/MenuItems.meta (77%) create mode 100644 MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs rename MCPForUnity/Editor/{ => MenuItems}/MCPForUnityMenu.cs.meta (100%) rename TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta => MCPForUnity/Editor/Migrations.meta (77%) create mode 100644 MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs create mode 100644 MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta create mode 100644 MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs create mode 100644 MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta create mode 100644 MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs create mode 100644 MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta delete mode 100644 MCPForUnity/Editor/Services/IPythonToolRegistryService.cs delete mode 100644 MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IServerManagementService.cs create mode 100644 MCPForUnity/Editor/Services/IServerManagementService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IToolDiscoveryService.cs create mode 100644 MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta delete mode 100644 MCPForUnity/Editor/Services/IToolSyncService.cs delete mode 100644 MCPForUnity/Editor/Services/IToolSyncService.cs.meta delete mode 100644 MCPForUnity/Editor/Services/PythonToolRegistryService.cs delete mode 100644 MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta create mode 100644 MCPForUnity/Editor/Services/ServerManagementService.cs create mode 100644 MCPForUnity/Editor/Services/ServerManagementService.cs.meta create mode 100644 MCPForUnity/Editor/Services/ToolDiscoveryService.cs create mode 100644 MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta delete mode 100644 MCPForUnity/Editor/Services/ToolSyncService.cs delete mode 100644 MCPForUnity/Editor/Services/ToolSyncService.cs.meta rename TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta => MCPForUnity/Editor/Services/Transport.meta (77%) create mode 100644 MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs create mode 100644 MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs create mode 100644 MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/TransportManager.cs create mode 100644 MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/TransportState.cs create mode 100644 MCPForUnity/Editor/Services/Transport/TransportState.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports.meta rename MCPForUnity/Editor/{MCPForUnityBridge.cs => Services/Transport/Transports/StdioBridgeHost.cs} (64%) create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta rename MCPForUnity/Editor/Setup/{SetupWizard.cs => SetupWindowService.cs} (71%) rename MCPForUnity/Editor/Setup/{SetupWizard.cs.meta => SetupWindowService.cs.meta} (100%) delete mode 100644 MCPForUnity/Editor/Setup/SetupWizardWindow.cs delete mode 100644 MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components.meta create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig.meta create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Common.uss create mode 100644 MCPForUnity/Editor/Windows/Components/Common.uss.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Connection.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Settings.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Settings/McpSettingsSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.cs create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.cs.meta create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uss create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uss.meta create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uxml create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uxml.meta delete mode 100644 MCPForUnity/UnityMcpServer~/src/Dockerfile delete mode 100644 MCPForUnity/UnityMcpServer~/src/config.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/models.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/pyproject.toml delete mode 100644 MCPForUnity/UnityMcpServer~/src/pyrightconfig.json delete mode 100644 MCPForUnity/UnityMcpServer~/src/resources/__init__.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/resources/project_info.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/resources/tests.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/resources/unity_instances.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/server.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/server_version.txt delete mode 100644 MCPForUnity/UnityMcpServer~/src/telemetry.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/telemetry_decorator.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/test_telemetry.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/__init__.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/conftest.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_helpers.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_improved_anchor_matching.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_instance_routing_comprehensive.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_instance_targeting_resolution.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_json_parsing_simple.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_logging_stdout.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_json_parsing.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_editing.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_endpoint_validation.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_queue_worker.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_subaction.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_transport_framing.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tests/pytest.ini delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/__init__.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/manage_script.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/read_console.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/run_tests.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/set_active_instance.py delete mode 100644 MCPForUnity/UnityMcpServer~/src/uv.lock delete mode 100644 Server/module_discovery.py delete mode 100644 Server/port_discovery.py delete mode 100644 Server/registry/__init__.py delete mode 100644 Server/registry/resource_registry.py delete mode 100644 Server/registry/tool_registry.py delete mode 100644 Server/reload_sentinel.py delete mode 100644 Server/resources/active_tool.py delete mode 100644 Server/resources/editor_state.py delete mode 100644 Server/resources/layers.py delete mode 100644 Server/resources/menu_items.py delete mode 100644 Server/resources/prefab_stage.py delete mode 100644 Server/resources/selection.py delete mode 100644 Server/resources/tags.py delete mode 100644 Server/resources/unity_instances.py delete mode 100644 Server/resources/windows.py delete mode 100644 Server/server.py delete mode 100644 Server/server_version.txt rename {MCPForUnity/UnityMcpServer~ => Server}/src/__init__.py (100%) rename {MCPForUnity/UnityMcpServer~/src/tests => Server/src/core}/__init__.py (100%) rename Server/{ => src/core}/config.py (77%) create mode 100644 Server/src/core/logging_decorator.py rename Server/{ => src/core}/telemetry.py (97%) rename Server/{ => src/core}/telemetry_decorator.py (98%) create mode 100644 Server/src/main.py create mode 100644 Server/src/models/__init__.py rename Server/{ => src/models}/models.py (60%) create mode 100644 Server/src/models/unity_response.py create mode 100644 Server/src/routes/__init__.py create mode 100644 Server/src/services/__init__.py create mode 100644 Server/src/services/custom_tool_service.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/registry/__init__.py (100%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/registry/resource_registry.py (100%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/registry/tool_registry.py (100%) rename Server/{ => src/services}/resources/__init__.py (70%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/active_tool.py (79%) create mode 100644 Server/src/services/resources/custom_tools.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/editor_state.py (80%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/layers.py (72%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/menu_items.py (70%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/prefab_stage.py (78%) rename Server/{ => src/services}/resources/project_info.py (77%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/selection.py (83%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/tags.py (71%) rename Server/{ => src/services}/resources/tests.py (83%) create mode 100644 Server/src/services/resources/unity_instances.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/resources/windows.py (79%) create mode 100644 Server/src/services/tools/__init__.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/tools/debug_request_context.py (93%) create mode 100644 Server/src/services/tools/execute_custom_tool.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/tools/execute_menu_item.py (68%) rename Server/{ => src/services}/tools/manage_asset.py (52%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/tools/manage_editor.py (88%) rename Server/{ => src/services}/tools/manage_gameobject.py (93%) rename Server/{ => src/services}/tools/manage_prefabs.py (81%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/services}/tools/manage_scene.py (79%) rename Server/{ => src/services}/tools/manage_script.py (92%) rename Server/{ => src/services}/tools/manage_shader.py (86%) rename Server/{ => src/services}/tools/read_console.py (85%) rename Server/{ => src/services}/tools/run_tests.py (84%) rename Server/{ => src/services}/tools/script_apply_edits.py (97%) create mode 100644 Server/src/services/tools/set_active_instance.py create mode 100644 Server/src/transport/__init__.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/transport/legacy}/port_discovery.py (86%) create mode 100644 Server/src/transport/legacy/stdio_port_registry.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/transport/legacy}/unity_connection.py (78%) create mode 100644 Server/src/transport/models.py create mode 100644 Server/src/transport/plugin_hub.py create mode 100644 Server/src/transport/plugin_registry.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/transport}/unity_instance_middleware.py (67%) create mode 100644 Server/src/transport/unity_transport.py rename {MCPForUnity/UnityMcpServer~/src => Server/src/utils}/module_discovery.py (100%) rename {MCPForUnity/UnityMcpServer~/src => Server/src/utils}/reload_sentinel.py (100%) delete mode 100644 Server/test_telemetry.py create mode 100644 Server/tests/integration/test_domain_reload_resilience.py delete mode 100644 Server/tools/__init__.py delete mode 100644 Server/tools/debug_request_context.py delete mode 100644 Server/tools/execute_menu_item.py delete mode 100644 Server/tools/manage_editor.py delete mode 100644 Server/tools/manage_scene.py delete mode 100644 Server/tools/resource_tools.py delete mode 100644 Server/tools/set_active_instance.py delete mode 100644 Server/unity_connection.py delete mode 100644 Server/unity_instance_middleware.py delete mode 100644 TestProjects/UnityMCPTests/Assets/Temp/MaterialDirectPropertiesTests.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Temp/MaterialParameterToolTests.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/PackageLifecycleManagerTests.cs delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/PackageLifecycleManagerTests.cs.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/DomainReloadResilienceTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/DomainReloadResilienceTests.cs.meta create mode 100644 docker-compose.yml create mode 100644 docs/images/networking-architecture.png create mode 100644 docs/v8_NEW_NETWORKING_SETUP.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5bc1e90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +Server/build +.git +.venv +__pycache__ +*.pyc +.DS_Store diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 9cb8e1c..e99be17 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -67,11 +67,19 @@ jobs: jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp mv MCPForUnity/package.json.tmp MCPForUnity/package.json - echo "Updating MCPForUnity/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" - sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/UnityMcpServer~/src/pyproject.toml" + echo "Updating Server/pyproject.toml to $NEW_VERSION" + sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "Server/pyproject.toml" - echo "Updating MCPForUnity/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" - echo "$NEW_VERSION" > "MCPForUnity/UnityMcpServer~/src/server_version.txt" + echo "Updating README.md version references to v$NEW_VERSION" + sed -i 's|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v[0-9]\+\.[0-9]\+\.[0-9]\+|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v'"$NEW_VERSION"'|g' README.md + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' README.md + + echo "Updating README-zh.md version references to v$NEW_VERSION" + sed -i 's|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v[0-9]\+\.[0-9]\+\.[0-9]\+|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v'"$NEW_VERSION"'|g' README-zh.md + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' README-zh.md + + echo "Updating Server/README.md version references to v$NEW_VERSION" + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' Server/README.md - name: Commit and push changes env: @@ -81,7 +89,7 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add MCPForUnity/package.json "MCPForUnity/UnityMcpServer~/src/pyproject.toml" "MCPForUnity/UnityMcpServer~/src/server_version.txt" + git add MCPForUnity/package.json "Server/pyproject.toml" README.md README-zh.md Server/README.md if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 513dfb7..49c6f7f 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -55,14 +55,13 @@ jobs: uv venv echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - if [ -f MCPForUnity/UnityMcpServer~/src/pyproject.toml ]; then - uv pip install -e MCPForUnity/UnityMcpServer~/src - elif [ -f MCPForUnity/UnityMcpServer~/src/requirements.txt ]; then - uv pip install -r MCPForUnity/UnityMcpServer~/src/requirements.txt - elif [ -f MCPForUnity/UnityMcpServer~/pyproject.toml ]; then - uv pip install -e MCPForUnity/UnityMcpServer~/ - elif [ -f MCPForUnity/UnityMcpServer~/requirements.txt ]; then - uv pip install -r MCPForUnity/UnityMcpServer~/requirements.txt + if [ -f Server/pyproject.toml ]; then + uv pip install -e Server + elif [ -f Server/requirements.txt ]; then + uv pip install -r Server/requirements.txt + else + echo "No MCP Python deps found (skipping)" + fi else echo "No MCP Python deps found (skipping)" fi @@ -217,7 +216,7 @@ jobs: -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${manual_args[@]}" \ - -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + -executeMethod MCPForUnity.Editor.Services.Transport.Transports.StdioBridgeHost.StartAutoConnect # ---------- Wait for Unity bridge ---------- - name: Wait for Unity bridge (robust) @@ -285,7 +284,7 @@ jobs: "mcpServers": { "unity": { "command": "uv", - "args": ["run","--active","--directory","MCPForUnity/UnityMcpServer~/src","python","server.py"], + "args": ["run","--active","--directory","Server","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8364d1b..2269758 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -4,7 +4,7 @@ on: push: branches: ["**"] paths: - - MCPForUnity/UnityMcpServer~/src/** + - Server/** - .github/workflows/python-tests.yml workflow_dispatch: {} @@ -26,13 +26,13 @@ jobs: - name: Install dependencies run: | - cd MCPForUnity/UnityMcpServer~/src + cd Server uv sync uv pip install -e ".[dev]" - name: Run tests run: | - cd MCPForUnity/UnityMcpServer~/src + cd Server uv run pytest tests/ -v --tb=short - name: Upload test results @@ -41,5 +41,5 @@ jobs: with: name: pytest-results path: | - MCPForUnity/UnityMcpServer~/src/.pytest_cache/ - MCPForUnity/UnityMcpServer~/src/tests/ + Server/.pytest_cache/ + Server/tests/ diff --git a/.gitignore b/.gitignore index d56cf6c..81f0d66 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,6 @@ build/ dist/ wheels/ *.egg-info -UnityMcpServer/**/*.meta -UnityMcpServer.meta # Virtual environments .venv diff --git a/MCPForUnity/Editor/Importers.meta b/MCPForUnity/Editor/Constants.meta similarity index 77% rename from MCPForUnity/Editor/Importers.meta rename to MCPForUnity/Editor/Constants.meta index 3d24208..7c23235 100644 --- a/MCPForUnity/Editor/Importers.meta +++ b/MCPForUnity/Editor/Constants.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b104663d2f6c648e1b99633082385db2 +guid: f7e009cbf3e74f6c987331c2b438ec59 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs new file mode 100644 index 0000000..ffaa31c --- /dev/null +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -0,0 +1,40 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Centralized list of EditorPrefs keys used by the MCP for Unity package. + /// Keeping them in one place avoids typos and simplifies migrations. + /// + internal static class EditorPrefKeys + { + internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport"; + internal const string DebugLogs = "MCPForUnity.DebugLogs"; + internal const string ValidationLevel = "MCPForUnity.ValidationLevel"; + internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort"; + internal const string ResumeHttpAfterReload = "MCPForUnity.ResumeHttpAfterReload"; + + internal const string UvxPathOverride = "MCPForUnity.UvxPath"; + internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath"; + + internal const string HttpBaseUrl = "MCPForUnity.HttpUrl"; + internal const string SessionId = "MCPForUnity.SessionId"; + internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; + internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + + internal const string ServerSrc = "MCPForUnity.ServerSrc"; + internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; + internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; + internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled"; + + internal const string SetupCompleted = "MCPForUnity.SetupCompleted"; + internal const string SetupDismissed = "MCPForUnity.SetupDismissed"; + + internal const string CustomToolRegistrationEnabled = "MCPForUnity.CustomToolRegistrationEnabled"; + + internal const string LastUpdateCheck = "MCPForUnity.LastUpdateCheck"; + internal const string LatestKnownVersion = "MCPForUnity.LatestKnownVersion"; + internal const string LastStdIoUpgradeVersion = "MCPForUnity.LastStdIoUpgradeVersion"; + + internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled"; + internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; + } +} diff --git a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta rename to MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta index f1e14f7..9c923da 100644 --- a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c40bd28f2310d463c8cd00181202cbe4 +guid: 7317786cfb9304b0db20ca73a774b9fa MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs b/MCPForUnity/Editor/Data/PythonToolsAsset.cs deleted file mode 100644 index 22719a5..0000000 --- a/MCPForUnity/Editor/Data/PythonToolsAsset.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEngine; - -namespace MCPForUnity.Editor.Data -{ - /// - /// Registry of Python tool files to sync to the MCP server. - /// Add your Python files here - they can be stored anywhere in your project. - /// - [CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")] - public class PythonToolsAsset : ScriptableObject - { - [Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")] - public List pythonFiles = new List(); - - [Header("Sync Options")] - [Tooltip("Use content hashing to detect changes (recommended). If false, always copies on startup.")] - public bool useContentHashing = true; - - [Header("Sync State (Read-only)")] - [Tooltip("Internal tracking - do not modify")] - public List fileStates = new List(); - - /// - /// Gets all valid Python files (filters out null/missing references) - /// - public IEnumerable GetValidFiles() - { - return pythonFiles.Where(f => f != null); - } - - /// - /// Checks if a file needs syncing - /// - public bool NeedsSync(TextAsset file, string currentHash) - { - if (!useContentHashing) return true; // Always sync if hashing disabled - - var state = fileStates.FirstOrDefault(s => s.assetGuid == GetAssetGuid(file)); - return state == null || state.contentHash != currentHash; - } - - /// - /// Records that a file was synced - /// - public void RecordSync(TextAsset file, string hash) - { - string guid = GetAssetGuid(file); - var state = fileStates.FirstOrDefault(s => s.assetGuid == guid); - - if (state == null) - { - state = new PythonFileState { assetGuid = guid }; - fileStates.Add(state); - } - - state.contentHash = hash; - state.lastSyncTime = DateTime.UtcNow; - state.fileName = file.name; - } - - /// - /// Removes state entries for files no longer in the list - /// - public void CleanupStaleStates() - { - var validGuids = new HashSet(GetValidFiles().Select(GetAssetGuid)); - fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid)); - } - - private string GetAssetGuid(TextAsset asset) - { - return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset)); - } - - /// - /// Called when the asset is modified in the Inspector - /// Triggers sync to handle file additions/removals - /// - private void OnValidate() - { - // Cleanup stale states immediately - CleanupStaleStates(); - - // Trigger sync after a delay to handle file removals - // Delay ensures the asset is saved before sync runs - UnityEditor.EditorApplication.delayCall += () => - { - if (this != null) // Check if asset still exists - { - MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools(); - } - }; - } - } - - [Serializable] - public class PythonFileState - { - public string assetGuid; - public string fileName; - public string contentHash; - public DateTime lastSyncTime; - } -} \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs index ce6efef..3f7b154 100644 --- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -56,14 +56,10 @@ namespace MCPForUnity.Editor.Dependencies var pythonStatus = detector.DetectPython(); result.Dependencies.Add(pythonStatus); - // Check UV - var uvStatus = detector.DetectUV(); + // Check uv + var uvStatus = detector.DetectUv(); result.Dependencies.Add(uvStatus); - // Check MCP Server - var serverStatus = detector.DetectMCPServer(); - result.Dependencies.Add(serverStatus); - // Generate summary and recommendations result.GenerateSummary(); GenerateRecommendations(result, detector); @@ -104,7 +100,7 @@ namespace MCPForUnity.Editor.Dependencies try { var detector = GetCurrentPlatformDetector(); - return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); + return (detector.GetPythonInstallUrl(), detector.GetUvInstallUrl()); } catch { @@ -128,9 +124,9 @@ namespace MCPForUnity.Editor.Dependencies { result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); } - else if (dep.Name == "UV Package Manager") + else if (dep.Name == "uv Package Manager") { - result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); + result.RecommendedActions.Add($"Install uv package manager from: {detector.GetUvInstallUrl()}"); } else if (dep.Name == "MCP Server") { @@ -140,7 +136,7 @@ namespace MCPForUnity.Editor.Dependencies if (result.GetMissingRequired().Count > 0) { - result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); + result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Setup Window) for guided installation."); } } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs index 7fba58f..3231105 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -23,14 +23,9 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors DependencyStatus DetectPython(); /// - /// Detect UV package manager on this platform + /// Detect uv package manager on this platform /// - DependencyStatus DetectUV(); - - /// - /// Detect MCP server installation on this platform - /// - DependencyStatus DetectMCPServer(); + DependencyStatus DetectUv(); /// /// Get platform-specific installation recommendations @@ -43,8 +38,8 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors string GetPythonInstallUrl(); /// - /// Get platform-specific UV installation URL + /// Get platform-specific uv installation URL /// - string GetUVInstallUrl(); + string GetUvInstallUrl(); } } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs index f654612..1c5bf45 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -25,45 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors try { - // Check common Python installation paths on Linux - var candidates = new[] + // Try running python directly first + if (TryValidatePython("python3", out string version, out string fullPath) || + TryValidatePython("python", out version, out fullPath)) { - "python3", - "python", - "/usr/bin/python3", - "/usr/local/bin/python3", - "/opt/python/bin/python3", - "/snap/bin/python3" - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; } - // Try PATH resolution using 'which' command + // Fallback: try 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { - if (TryValidatePython(pathResult, out string version, out string fullPath)) + if (TryValidatePython(pathResult, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; + status.Details = $"Found Python {version} in PATH"; return status; } } - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths including system, snap, and user-local locations."; + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -78,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors return "https://www.python.org/downloads/source/"; } - public override string GetUVInstallUrl() + public override string GetUvInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#linux"; } @@ -93,7 +81,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors - Arch: sudo pacman -S python python-pip - Or use pyenv: https://github.com/pyenv/pyenv -2. UV Package Manager: Install via curl +2. uv Package Manager: Install via curl - Run: curl -LsSf https://astral.sh/uv/install.sh | sh - Or download from: https://github.com/astral-sh/uv/releases @@ -102,6 +90,51 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors Note: Make sure ~/.local/bin is in your PATH for user-local installations."; } + public override DependencyStatus DetectUv() + { + var status = new DependencyStatus("uv Package Manager", isRequired: true) + { + InstallationHint = GetUvInstallUrl() + }; + + try + { + // Try running uv/uvx directly with augmented PATH + if (TryValidateUv("uv", out string version, out string fullPath) || + TryValidateUv("uvx", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + return status; + } + + // Fallback: use which with augmented PATH + if (TryFindInPath("uv", out string pathResult) || + TryFindInPath("uvx", out pathResult)) + { + if (TryValidateUv(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; @@ -159,6 +192,65 @@ Note: Make sure ~/.local/bin is in your PATH for user-local installations."; return false; } + private bool TryValidateUv(string uvPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + psi.EnvironmentVariables["PATH"] = BuildAugmentedPath(); + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + fullPath = uvPath; + return true; + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private string BuildAugmentedPath() + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + return string.Join(":", GetPathAdditions()) + ":" + currentPath; + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs index 7d54d23..a3ced1b 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -25,49 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors try { - // Check common Python installation paths on macOS - var candidates = new[] + // Try running python directly first + if (TryValidatePython("python3", out string version, out string fullPath) || + TryValidatePython("python", out version, out fullPath)) { - "python3", - "python", - "/usr/bin/python3", - "/usr/local/bin/python3", - "/opt/homebrew/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.14/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; } - // Try PATH resolution using 'which' command + // Fallback: try 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { - if (TryValidatePython(pathResult, out string version, out string fullPath)) + if (TryValidatePython(pathResult, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; + status.Details = $"Found Python {version} in PATH"; return status; } } - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -82,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors return "https://www.python.org/downloads/macos/"; } - public override string GetUVInstallUrl() + public override string GetUvInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#macos"; } @@ -95,7 +79,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors - Homebrew: brew install python3 - Direct download: https://python.org/downloads/macos/ -2. UV Package Manager: Install via curl or Homebrew +2. uv Package Manager: Install via curl or Homebrew - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh - Homebrew: brew install uv @@ -104,6 +88,51 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; } + public override DependencyStatus DetectUv() + { + var status = new DependencyStatus("uv Package Manager", isRequired: true) + { + InstallationHint = GetUvInstallUrl() + }; + + try + { + // Try running uv/uvx directly with augmented PATH + if (TryValidateUv("uv", out string version, out string fullPath) || + TryValidateUv("uvx", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + return status; + } + + // Fallback: use which with augmented PATH + if (TryFindInPath("uv", out string pathResult) || + TryFindInPath("uvx", out pathResult)) + { + if (TryValidateUv(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; @@ -160,6 +189,67 @@ Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; return false; } + private bool TryValidateUv(string uvPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + var augmentedPath = BuildAugmentedPath(); + psi.EnvironmentVariables["PATH"] = augmentedPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + fullPath = uvPath; + return true; + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private string BuildAugmentedPath() + { + var pathAdditions = GetPathAdditions(); + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + return string.Join(":", pathAdditions) + ":" + currentPath; + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs index 98044f1..c64881c 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -1,8 +1,6 @@ using System; using System.Diagnostics; -using System.IO; using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -16,122 +14,78 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors public abstract DependencyStatus DetectPython(); public abstract string GetPythonInstallUrl(); - public abstract string GetUVInstallUrl(); + public abstract string GetUvInstallUrl(); public abstract string GetInstallationRecommendations(); - public virtual DependencyStatus DetectUV() + public virtual DependencyStatus DetectUv() { - var status = new DependencyStatus("UV Package Manager", isRequired: true) + var status = new DependencyStatus("uv Package Manager", isRequired: true) { - InstallationHint = GetUVInstallUrl() + InstallationHint = GetUvInstallUrl() }; try { - // Use existing UV detection from ServerInstaller - string uvPath = ServerInstaller.FindUvPath(); - if (!string.IsNullOrEmpty(uvPath)) - { - if (TryValidateUV(uvPath, out string version)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = uvPath; - status.Details = $"Found UV {version} at {uvPath}"; - return status; - } - } - - status.ErrorMessage = "UV package manager not found. Please install UV."; - status.Details = "UV is required for managing Python dependencies."; - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting UV: {ex.Message}"; - } - - return status; - } - - public virtual DependencyStatus DetectMCPServer() - { - var status = new DependencyStatus("MCP Server", isRequired: false); - - try - { - // Check if server is installed - string serverPath = ServerInstaller.GetServerPath(); - string serverPy = Path.Combine(serverPath, "server.py"); - - if (File.Exists(serverPy)) + // Try to find uv/uvx in PATH + if (TryFindUvInPath(out string uvPath, out string version)) { status.IsAvailable = true; - status.Path = serverPath; - - // Try to get version - string versionFile = Path.Combine(serverPath, "server_version.txt"); - if (File.Exists(versionFile)) - { - status.Version = File.ReadAllText(versionFile).Trim(); - } - - status.Details = $"MCP Server found at {serverPath}"; - } - else - { - // Check for embedded server - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) - { - status.IsAvailable = true; - status.Path = embeddedPath; - status.Details = "MCP Server available (embedded in package)"; - } - else - { - status.ErrorMessage = "MCP Server not found"; - status.Details = "Server will be installed automatically when needed"; - } + status.Version = version; + status.Path = uvPath; + status.Details = $"Found uv {version} in PATH"; + return status; } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; } catch (Exception ex) { - status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; } return status; } - protected bool TryValidateUV(string uvPath, out string version) + protected bool TryFindUvInPath(out string uvPath, out string version) { + uvPath = null; version = null; - try + // Try common uv command names + var commands = new[] { "uvx", "uv" }; + + foreach (var cmd in commands) { - var psi = new ProcessStartInfo + try { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + var psi = new ProcessStartInfo + { + FileName = cmd, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; - using var process = Process.Start(psi); - if (process == null) return false; + using var process = Process.Start(psi); + if (process == null) continue; - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); - if (process.ExitCode == 0 && output.StartsWith("uv ")) - { - version = output.Substring(3); // Remove "uv " prefix - return true; + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + uvPath = cmd; + return true; + } + } + catch + { + // Try next command } - } - catch - { - // Ignore validation errors } return false; diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs index 6a534eb..e4d7b92 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -25,61 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors try { - // Check common Python installation paths - var candidates = new[] + // 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)) { - "python.exe", - "python3.exe", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python314", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python313", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python312", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python311", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", "Python", "Python310", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python314", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python313", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python312", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python311", "python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Python310", "python.exe") - }; + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } - foreach (var candidate in candidates) + // Fallback: try 'where' command + if (TryFindInPath("python3.exe", out string pathResult) || + TryFindInPath("python.exe", out pathResult)) { - if (TryValidatePython(candidate, out string version, out string fullPath)) + if (TryValidatePython(pathResult, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; + status.Details = $"Found Python {version} in PATH"; return status; } } - // Try PATH resolution using 'where' command - if (TryFindInPath("python.exe", out string pathResult) || - TryFindInPath("python3.exe", out pathResult)) - { - if (TryValidatePython(pathResult, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; - return status; - } - } - - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths and PATH environment variable."; + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -94,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; } - public override string GetUVInstallUrl() + public override string GetUvInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#windows"; } @@ -107,7 +79,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors - Microsoft Store: Search for 'Python 3.10' or higher - Direct download: https://python.org/downloads/windows/ -2. UV Package Manager: Install via PowerShell +2. uv Package Manager: Install via PowerShell - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" - Or download from: https://github.com/astral-sh/uv/releases diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index dac1fac..a310c6e 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; @@ -49,7 +51,7 @@ namespace MCPForUnity.Editor.Helpers // Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity) string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}"); - + if (guids.Length == 0) { McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase"); @@ -57,11 +59,11 @@ namespace MCPForUnity.Editor.Helpers } string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]); - + // Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs // Extract {packageRoot} int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal); - + if (editorIndex >= 0) { return scriptPath.Substring(0, editorIndex); @@ -136,7 +138,45 @@ namespace MCPForUnity.Editor.Helpers } /// - /// Gets the version string from the package.json file. + /// Gets just the git URL part for the MCP server package + /// Checks for EditorPrefs override first, then falls back to package version + /// + /// Git URL string, or empty string if version is unknown and no override + public static string GetMcpServerGitUrl() + { + // Check for Git URL override first + string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + if (!string.IsNullOrEmpty(gitUrlOverride)) + { + return gitUrlOverride; + } + + // Fall back to default package version + string version = GetPackageVersion(); + if (version == "unknown") + { + // Fall back to main repo without pinned version so configs remain valid in test scenarios + return "git+https://github.com/CoplayDev/unity-mcp#subdirectory=Server"; + } + + return $"git+https://github.com/CoplayDev/unity-mcp@v{version}#subdirectory=Server"; + } + + /// + /// Gets structured uvx command parts for different client configurations + /// + /// Tuple containing (uvxPath, fromUrl, packageName) + public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string fromUrl = GetMcpServerGitUrl(); + string packageName = "mcp-for-unity"; + + return (uvxPath, fromUrl, packageName); + } + + /// + /// Gets the package version from package.json /// /// Version string, or "unknown" if not found public static string GetPackageVersion() diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs index a472890..b4f786e 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using MCPForUnity.External.Tommy; using MCPForUnity.Editor.Services; +using UnityEditor; namespace MCPForUnity.Editor.Helpers { @@ -14,36 +15,50 @@ namespace MCPForUnity.Editor.Helpers /// public static class CodexConfigHelper { - public static bool IsCodexConfigured(string pythonDir) - { - try - { - string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (string.IsNullOrEmpty(basePath)) return false; - - string configPath = Path.Combine(basePath, ".codex", "config.toml"); - if (!File.Exists(configPath)) return false; - - string toml = File.ReadAllText(configPath); - if (!TryParseCodexServer(toml, out _, out var args)) return false; - - string dir = McpConfigurationHelper.ExtractDirectoryArg(args); - if (string.IsNullOrEmpty(dir)) return false; - - return McpConfigurationHelper.PathsEqual(dir, pythonDir); - } - catch - { - return false; - } - } - - public static string BuildCodexServerBlock(string uvPath, string serverSrc) + public static string BuildCodexServerBlock(string uvPath) { var table = new TomlTable(); var mcpServers = new TomlTable(); + var unityMCP = new TomlTable(); - mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + } + else + { + // Stdio mode: Use command and args + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + unityMCP["command"] = uvxPath; + + var args = new TomlArray(); + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Add(new TomlString { Value = "--from" }); + args.Add(new TomlString { Value = fromUrl }); + } + args.Add(new TomlString { Value = packageName }); + args.Add(new TomlString { Value = "--transport" }); + args.Add(new TomlString { Value = "stdio" }); + + unityMCP["args"] = args; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } + } + + mcpServers["unityMCP"] = unityMCP; table["mcp_servers"] = mcpServers; using var writer = new StringWriter(); @@ -51,7 +66,7 @@ namespace MCPForUnity.Editor.Helpers return writer.ToString(); } - public static string UpsertCodexServerBlock(string existingToml, string uvPath, string serverSrc) + public static string UpsertCodexServerBlock(string existingToml, string uvPath) { // Parse existing TOML or create new root table var root = TryParseToml(existingToml) ?? new TomlTable(); @@ -64,7 +79,7 @@ namespace MCPForUnity.Editor.Helpers var mcpServers = root["mcp_servers"] as TomlTable; // Create or update unityMCP table - mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); + mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath); // Serialize back to TOML using var writer = new StringWriter(); @@ -73,9 +88,15 @@ namespace MCPForUnity.Editor.Helpers } public static bool TryParseCodexServer(string toml, out string command, out string[] args) + { + return TryParseCodexServer(toml, out command, out args, out _); + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args, out string url) { command = null; args = null; + url = null; var root = TryParseToml(toml); if (root == null) return false; @@ -91,6 +112,15 @@ namespace MCPForUnity.Editor.Helpers return false; } + // Check for HTTP mode (url field) + url = GetTomlString(unity, "url"); + if (!string.IsNullOrEmpty(url)) + { + // HTTP mode detected - return true with url + return true; + } + + // Check for stdio mode (command + args) command = GetTomlString(unity, "command"); args = GetTomlStringArray(unity, "args"); @@ -126,27 +156,45 @@ namespace MCPForUnity.Editor.Helpers /// /// Creates a TomlTable for the unityMCP server configuration /// - /// Path to uv executable - /// Path to server source directory - private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc) + /// Path to uv executable (used as fallback if uvx is not available) + private static TomlTable CreateUnityMcpTable(string uvPath) { var unityMCP = new TomlTable(); - unityMCP["command"] = new TomlString { Value = uvPath }; - var argsArray = new TomlArray(); - argsArray.Add(new TomlString { Value = "run" }); - argsArray.Add(new TomlString { Value = "--directory" }); - argsArray.Add(new TomlString { Value = serverSrc }); - argsArray.Add(new TomlString { Value = "server.py" }); - unityMCP["args"] = argsArray; + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); - // Add Windows-specific environment configuration, see: https://github.com/CoplayDev/unity-mcp/issues/315 - var platformService = MCPServiceLocator.Platform; - if (platformService.IsWindows()) + if (useHttpTransport) { - var envTable = new TomlTable { IsInline = true }; - envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; - unityMCP["env"] = envTable; + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + } + else + { + // Stdio mode: Use command and args + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + unityMCP["command"] = new TomlString { Value = uvxPath }; + + var argsArray = new TomlArray(); + if (!string.IsNullOrEmpty(fromUrl)) + { + argsArray.Add(new TomlString { Value = "--from" }); + argsArray.Add(new TomlString { Value = fromUrl }); + } + argsArray.Add(new TomlString { Value = packageName }); + argsArray.Add(new TomlString { Value = "--transport" }); + argsArray.Add(new TomlString { Value = "stdio" }); + unityMCP["args"] = argsArray; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } } return unityMCP; diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 5889e4f..084e2a7 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -1,12 +1,16 @@ using Newtonsoft.Json; +using System.Collections.Generic; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; +using UnityEditor; namespace MCPForUnity.Editor.Helpers { public static class ConfigJsonBuilder { - public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) + public static string BuildManualConfigJson(string uvPath, McpClient client) { var root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; @@ -21,20 +25,20 @@ namespace MCPForUnity.Editor.Helpers } var unity = new JObject(); - PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); + PopulateUnityNode(unity, uvPath, client, isVSCode); container["unityMCP"] = unity; return root.ToString(Formatting.Indented); } - public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) + public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client) { if (root == null) root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject unity = container["unityMCP"] as JObject ?? new JObject(); - PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); + PopulateUnityNode(unity, uvPath, client, isVSCode); container["unityMCP"] = unity; return root; @@ -42,79 +46,93 @@ namespace MCPForUnity.Editor.Helpers /// /// Centralized builder that applies all caveats consistently. - /// - Sets command/args with provided directory + /// - Sets command/args with uvx and package version /// - Ensures env exists - /// - Adds type:"stdio" for VSCode + /// - Adds transport configuration (HTTP or stdio) /// - Adds disabled:false for Windsurf/Kiro only when missing /// - private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) + private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) { - unity["command"] = uvPath; + // Get transport preference (default to HTTP) + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool isWindsurf = client?.mcpType == McpTypes.Windsurf; - // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners - string effectiveDir = directory; -#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX - bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); - if (isCursor && !string.IsNullOrEmpty(directory)) + if (useHttpTransport) { - // Replace canonical path segment with the symlink path if present - const string canonical = "/Library/Application Support/"; - const string symlinkSeg = "/Library/AppSupport/"; - try + // HTTP mode: Use URL, no command + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + string httpProperty = isWindsurf ? "serverUrl" : "url"; + unity[httpProperty] = httpUrl; + + // Remove legacy property for Windsurf (or vice versa) + string staleProperty = isWindsurf ? "url" : "serverUrl"; + if (unity[staleProperty] != null) { - // Normalize to full path style - if (directory.Contains(canonical)) - { - var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); - if (System.IO.Directory.Exists(candidate)) - { - effectiveDir = candidate; - } - } - else - { - // If installer returned XDG-style on macOS, map to canonical symlink - string norm = directory.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); - if (idx >= 0) - { - string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... - string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); - if (System.IO.Directory.Exists(candidate)) - { - effectiveDir = candidate; - } - } - } + unity.Remove(staleProperty); } - catch { /* fallback to original directory on any error */ } - } -#endif - unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); + // Remove command/args if they exist from previous config + if (unity["command"] != null) unity.Remove("command"); + if (unity["args"] != null) unity.Remove("args"); - if (isVSCode) - { - unity["type"] = "stdio"; + if (isVSCode) + { + unity["type"] = "http"; + } } else { - // Remove type if it somehow exists from previous clients - if (unity["type"] != null) unity.Remove("type"); + // Stdio mode: Use uvx command + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + unity["command"] = uvxPath; + + var args = new List { packageName }; + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Insert(0, fromUrl); + args.Insert(0, "--from"); + } + + args.Add("--transport"); + args.Add("stdio"); + + unity["args"] = JArray.FromObject(args.ToArray()); + + // Remove url/serverUrl if they exist from previous config + if (unity["url"] != null) unity.Remove("url"); + if (unity["serverUrl"] != null) unity.Remove("serverUrl"); + + if (isVSCode) + { + unity["type"] = "stdio"; + } } - if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) + // Remove type for non-VSCode clients + if (!isVSCode && unity["type"] != null) + { + unity.Remove("type"); + } + + bool requiresEnv = client?.mcpType == McpTypes.Kiro; + bool requiresDisabled = client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro); + + if (requiresEnv) { if (unity["env"] == null) { unity["env"] = new JObject(); } + } + else if (isWindsurf && unity["env"] != null) + { + unity.Remove("env"); + } - if (unity["disabled"] == null) - { - unity["disabled"] = false; - } + if (requiresDisabled && unity["disabled"] == null) + { + unity["disabled"] = false; } } diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 20c1200..9190ec3 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -5,12 +5,13 @@ using System.Linq; using System.Text; using System.Runtime.InteropServices; using UnityEditor; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { internal static class ExecPath { - private const string PrefClaude = "MCPForUnity.ClaudeCliPath"; + private const string PrefClaude = EditorPrefKeys.ClaudeCliPathOverride; // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. internal static string ResolveClaude() @@ -157,12 +158,6 @@ namespace MCPForUnity.Editor.Helpers catch { } } - // Use existing UV resolver; returns absolute path or null. - internal static string ResolveUv() - { - return ServerInstaller.FindUvPath(); - } - internal static bool TryRun( string file, string args, diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs index f9abf1f..05d1b8b 100644 --- a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -255,25 +255,25 @@ namespace MCPForUnity.Editor.Helpers var declaredFields = currentType.GetFields(fieldFlags); // Process the declared Fields for caching - foreach (var fieldInfo in declaredFields) + foreach (var fieldInfo in declaredFields) { if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields // Add if not already added (handles hiding - keep the most derived version) if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; - bool shouldInclude = false; - if (includeNonPublicSerializedFields) - { - // If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal) - var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true); - shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField); - } - else // includeNonPublicSerializedFields is FALSE - { - // If FALSE, include ONLY if it is explicitly Public. - shouldInclude = fieldInfo.IsPublic; - } + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal) + var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true); + shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } if (shouldInclude) { @@ -358,7 +358,7 @@ namespace MCPForUnity.Editor.Helpers // --- Add detailed logging --- // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); // --- End detailed logging --- - + // --- Special handling for material/mesh properties in edit mode --- object value; if (!Application.isPlaying && (propName == "material" || propName == "materials" || propName == "mesh")) @@ -386,7 +386,7 @@ namespace MCPForUnity.Editor.Helpers value = propInfo.GetValue(c); } // --- End special handling --- - + Type propType = propInfo.PropertyType; AddSerializableValue(serializablePropertiesOutput, propName, propType, value); } diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs new file mode 100644 index 0000000..bda33cb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -0,0 +1,86 @@ +using System; +using UnityEditor; +using MCPForUnity.Editor.Constants; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Helper methods for managing HTTP endpoint URLs used by the MCP bridge. + /// Ensures the stored value is always the base URL (without trailing path), + /// and provides convenience accessors for specific endpoints. + /// + public static class HttpEndpointUtility + { + private const string PrefKey = EditorPrefKeys.HttpBaseUrl; + private const string DefaultBaseUrl = "http://localhost:8080"; + + /// + /// Returns the normalized base URL currently stored in EditorPrefs. + /// + public static string GetBaseUrl() + { + string stored = EditorPrefs.GetString(PrefKey, DefaultBaseUrl); + return NormalizeBaseUrl(stored); + } + + /// + /// Saves a user-provided URL after normalizing it to a base form. + /// + public static void SaveBaseUrl(string userValue) + { + string normalized = NormalizeBaseUrl(userValue); + EditorPrefs.SetString(PrefKey, normalized); + } + + /// + /// Builds the JSON-RPC endpoint used by FastMCP clients (base + /mcp). + /// + public static string GetMcpRpcUrl() + { + return AppendPathSegment(GetBaseUrl(), "mcp"); + } + + /// + /// Builds the endpoint used when POSTing custom-tool registration payloads. + /// + public static string GetRegisterToolsUrl() + { + return AppendPathSegment(GetBaseUrl(), "register-tools"); + } + + /// + /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). + /// + private static string NormalizeBaseUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return DefaultBaseUrl; + } + + string trimmed = value.Trim(); + + // Ensure scheme exists; default to http:// if user omitted it. + if (!trimmed.Contains("://")) + { + trimmed = $"http://{trimmed}"; + } + + // Remove trailing slash segments. + trimmed = trimmed.TrimEnd('/'); + + // Strip trailing "/mcp" (case-insensitive) if provided. + if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^4]; + } + + return trimmed; + } + + private static string AppendPathSegment(string baseUrl, string segment) + { + return $"{baseUrl.TrimEnd('/')}/{segment}"; + } + } +} diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta similarity index 83% rename from MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta rename to MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta index bfe30d9..55d67cb 100644 --- a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1ad9865b38bcc4efe85d4970c6d3a997 +guid: 2051d90316ea345c09240c80c7138e3b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 96ad7ec..2552f9a 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -3,13 +3,15 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Helpers { @@ -19,13 +21,13 @@ namespace MCPForUnity.Editor.Helpers /// public static class McpConfigurationHelper { - private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; + private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig; /// /// Writes MCP configuration to the specified path using sophisticated logic /// that preserves existing configuration and only writes when necessary /// - public static string WriteMcpConfiguration(string pythonDir, string configPath, McpClient mcpClient = null) + public static string WriteMcpConfiguration(string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) try @@ -94,19 +96,8 @@ namespace MCPForUnity.Editor.Helpers catch { } // 1) Start from existing, only fill gaps (prefer trusted resolver) - string uvPath = ServerInstaller.FindUvPath(); - // Optionally trust existingCommand if it looks like uv/uv.exe - try - { - var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); - if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) - { - uvPath = existingCommand; - } - } - catch { } - if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) return "uv package manager not found. Please install uv first."; // Ensure containers exist and write back configuration JObject existingRoot; @@ -115,27 +106,20 @@ namespace MCPForUnity.Editor.Helpers else existingRoot = JObject.FromObject(existingConfig); - existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); EnsureConfigDirectoryExists(configPath); WriteAtomicFile(configPath, mergedJson); - try - { - if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } - return "Configured successfully"; } /// /// Configures a Codex client with sophisticated TOML handling /// - public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) + public static string ConfigureCodexClient(string configPath, McpClient mcpClient) { try { @@ -165,66 +149,20 @@ namespace MCPForUnity.Editor.Helpers CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); } - string uvPath = ServerInstaller.FindUvPath(); - try + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) { - var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); - if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) - { - uvPath = existingCommand; - } - } - catch { } - - if (uvPath == null) - { - return "UV package manager not found. Please install UV first."; + return "uv package manager not found. Please install uv first."; } - string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); - - string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath, serverSrc); + string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath); EnsureConfigDirectoryExists(configPath); WriteAtomicFile(configPath, updatedToml); - try - { - if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } - return "Configured successfully"; } - /// - /// Validates UV binary by running --version command - /// - private static bool IsValidUvBinary(string path) - { - try - { - if (!File.Exists(path)) return false; - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = path, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p == null) return false; - if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode != 0) return false; - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - catch { return false; } - } - /// /// Gets the appropriate config file path for the given MCP client based on OS /// @@ -258,12 +196,12 @@ namespace MCPForUnity.Editor.Helpers Directory.CreateDirectory(Path.GetDirectoryName(configPath)); } - public static string ExtractDirectoryArg(string[] args) + public static string ExtractUvxUrl(string[] args) { if (args == null) return null; for (int i = 0; i < args.Length - 1; i++) { - if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(args[i], "--from", StringComparison.OrdinalIgnoreCase)) { return args[i + 1]; } @@ -290,58 +228,6 @@ namespace MCPForUnity.Editor.Helpers } } - /// - /// Resolves the server directory to use for MCP tools, preferring - /// existing config values and falling back to installed/embedded copies. - /// - public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) - { - string serverSrc = ExtractDirectoryArg(existingArgs); - bool serverValid = !string.IsNullOrEmpty(serverSrc) - && File.Exists(Path.Combine(serverSrc, "server.py")); - if (!serverValid) - { - if (!string.IsNullOrEmpty(pythonDir) - && File.Exists(Path.Combine(pythonDir, "server.py"))) - { - serverSrc = pythonDir; - } - else - { - serverSrc = ResolveServerSource(); - } - } - - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) - { - string norm = serverSrc.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); - if (idx >= 0) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); - serverSrc = Path.Combine(home, "Library", "Application Support", suffix); - } - } - } - catch - { - // Ignore failures and fall back to the original path. - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - && !string.IsNullOrEmpty(serverSrc) - && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 - && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) - { - serverSrc = ServerInstaller.GetServerPath(); - } - - return serverSrc; - } - public static void WriteAtomicFile(string path, string contents) { string tmp = path + ".tmp"; @@ -393,39 +279,5 @@ namespace MCPForUnity.Editor.Helpers try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } } } - - public static string ResolveServerSource() - { - try - { - string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); - if (!string.IsNullOrEmpty(remembered) - && File.Exists(Path.Combine(remembered, "server.py"))) - { - return remembered; - } - - ServerInstaller.EnsureServerInstalled(); - string installed = ServerInstaller.GetServerPath(); - if (File.Exists(Path.Combine(installed, "server.py"))) - { - return installed; - } - - bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); - if (useEmbedded - && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) - && File.Exists(Path.Combine(embedded, "server.py"))) - { - return embedded; - } - - return installed; - } - catch - { - return ServerInstaller.GetServerPath(); - } - } } } diff --git a/MCPForUnity/Editor/Helpers/McpJobStateStore.cs b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs new file mode 100644 index 0000000..5db093b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility for persisting tool state across domain reloads. State is stored in + /// Library so it stays local to the project and is cleared by Unity as needed. + /// + public static class McpJobStateStore + { + private static string GetStatePath(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + { + throw new ArgumentException("toolName cannot be null or empty", nameof(toolName)); + } + + var libraryPath = Path.Combine(Application.dataPath, "..", "Library"); + var fileName = $"McpState_{toolName}.json"; + return Path.GetFullPath(Path.Combine(libraryPath, fileName)); + } + + public static void SaveState(string toolName, T state) + { + var path = GetStatePath(toolName); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance()); + File.WriteAllText(path, json); + } + + public static T LoadState(string toolName) + { + var path = GetStatePath(toolName); + if (!File.Exists(path)) + { + return default; + } + + try + { + var json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); + } + catch (Exception) + { + return default; + } + } + + public static void ClearState(string toolName) + { + var path = GetStatePath(toolName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta rename to MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta index d3a3719..df45ef5 100644 --- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta +++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4bdcf382960c842aab0a08c90411ab43 +guid: 28912085dd68342f8a9fda8a43c83a59 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs b/MCPForUnity/Editor/Helpers/McpLog.cs index 8d31c55..2b0a314 100644 --- a/MCPForUnity/Editor/Helpers/McpLog.cs +++ b/MCPForUnity/Editor/Helpers/McpLog.cs @@ -1,33 +1,53 @@ using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { internal static class McpLog { - private const string LogPrefix = "MCP-FOR-UNITY:"; + private const string InfoPrefix = "MCP-FOR-UNITY:"; + private const string DebugPrefix = "MCP-FOR-UNITY:"; private const string WarnPrefix = "MCP-FOR-UNITY:"; private const string ErrorPrefix = "MCP-FOR-UNITY:"; - private static bool IsDebugEnabled() + private static volatile bool _debugEnabled = ReadDebugPreference(); + + private static bool IsDebugEnabled() => _debugEnabled; + + private static bool ReadDebugPreference() { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } + catch { return false; } + } + + public static void SetDebugLoggingEnabled(bool enabled) + { + _debugEnabled = enabled; + try { EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, enabled); } + catch { } + } + + public static void Debug(string message) + { + if (!IsDebugEnabled()) return; + UnityEngine.Debug.Log($"{DebugPrefix} {message}"); } public static void Info(string message, bool always = true) { if (!always && !IsDebugEnabled()) return; - Debug.Log($"{LogPrefix} {message}"); + UnityEngine.Debug.Log($"{InfoPrefix} {message}"); } public static void Warn(string message) { - Debug.LogWarning($"{WarnPrefix} {message}"); + UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}"); } public static void Error(string message) { - Debug.LogError($"{ErrorPrefix} {message}"); + UnityEngine.Debug.LogError($"{ErrorPrefix} {message}"); } } } diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs b/MCPForUnity/Editor/Helpers/McpPathResolver.cs deleted file mode 100644 index 04082a9..0000000 --- a/MCPForUnity/Editor/Helpers/McpPathResolver.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.IO; -using UnityEngine; -using UnityEditor; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Shared helper for resolving MCP server directory paths with support for - /// development mode, embedded servers, and installed packages - /// - public static class McpPathResolver - { - private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; - - /// - /// Resolves the MCP server directory path with comprehensive logic - /// including development mode support and fallback mechanisms - /// - public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) - { - string pythonDir = McpConfigurationHelper.ResolveServerSource(); - - try - { - // Only check dev paths if we're using a file-based package (development mode) - bool isDevelopmentMode = IsDevelopmentMode(); - if (isDevelopmentMode) - { - string currentPackagePath = Path.GetDirectoryName(Application.dataPath); - string[] devPaths = { - Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), - }; - - foreach (string devPath in devPaths) - { - if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) - { - if (debugLogsEnabled) - { - Debug.Log($"Currently in development mode. Package: {devPath}"); - } - return devPath; - } - } - } - - // Resolve via shared helper (handles local registry and older fallback) only if dev override on - if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, false)) - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) - { - return embedded; - } - } - - // Log only if the resolved path does not actually contain server.py - if (debugLogsEnabled) - { - bool hasServer = false; - try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } - if (!hasServer) - { - Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); - } - } - } - catch (Exception e) - { - Debug.LogError($"Error finding package path: {e.Message}"); - } - - return pythonDir; - } - - /// - /// Checks if the current Unity project is in development mode - /// (i.e., the package is referenced as a local file path in manifest.json) - /// - private static bool IsDevelopmentMode() - { - try - { - // Only treat as development if manifest explicitly references a local file path for the package - string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); - if (!File.Exists(manifestPath)) return false; - - string manifestContent = File.ReadAllText(manifestPath); - // Look specifically for our package dependency set to a file: URL - // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk - if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) - { - int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); - // Crude but effective: check for "file:" in the same line/value - if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 - && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - catch - { - return false; - } - } - - /// - /// Gets the appropriate PATH prepend for the current platform when running external processes - /// - public static string GetPathPrepend() - { - if (Application.platform == RuntimePlatform.OSXEditor) - return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; - else if (Application.platform == RuntimePlatform.LinuxEditor) - return "/usr/local/bin:/usr/bin:/bin"; - return null; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs b/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs deleted file mode 100644 index 02e482c..0000000 --- a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System.IO; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Manages package lifecycle events including first-time installation, - /// version updates, and legacy installation detection. - /// Consolidates the functionality of PackageInstaller and PackageDetector. - /// - [InitializeOnLoad] - public static class PackageLifecycleManager - { - private const string VersionKeyPrefix = "MCPForUnity.InstalledVersion:"; - private const string LegacyInstallFlagKey = "MCPForUnity.ServerInstalled"; // For migration - private const string InstallErrorKeyPrefix = "MCPForUnity.InstallError:"; // Stores last installation error - - static PackageLifecycleManager() - { - // Schedule the check for after Unity is fully loaded - EditorApplication.delayCall += CheckAndInstallServer; - } - - private static void CheckAndInstallServer() - { - try - { - string currentVersion = GetPackageVersion(); - string versionKey = VersionKeyPrefix + currentVersion; - bool hasRunForThisVersion = EditorPrefs.GetBool(versionKey, false); - - // Check for conditions that require installation/verification - bool isFirstTimeInstall = !EditorPrefs.HasKey(LegacyInstallFlagKey) && !hasRunForThisVersion; - bool legacyPresent = LegacyRootsExist(); - bool canonicalMissing = !File.Exists( - Path.Combine(ServerInstaller.GetServerPath(), "server.py") - ); - - // Run if: first install, version update, legacy detected, or canonical missing - if (isFirstTimeInstall || !hasRunForThisVersion || legacyPresent || canonicalMissing) - { - PerformInstallation(currentVersion, versionKey, isFirstTimeInstall); - } - } - catch (System.Exception ex) - { - McpLog.Info($"Package lifecycle check failed: {ex.Message}. Open Window > MCP For Unity if needed.", always: false); - } - } - - private static void PerformInstallation(string version, string versionKey, bool isFirstTimeInstall) - { - string error = null; - - try - { - ServerInstaller.EnsureServerInstalled(); - - // Mark as installed for this version - EditorPrefs.SetBool(versionKey, true); - - // Migrate legacy flag if this is first time - if (isFirstTimeInstall) - { - EditorPrefs.SetBool(LegacyInstallFlagKey, true); - } - - // Clean up old version keys (keep only current version) - CleanupOldVersionKeys(version); - - // Clean up legacy preference keys - CleanupLegacyPrefs(); - - // Only log success if server was actually embedded and copied - if (ServerInstaller.HasEmbeddedServer() && isFirstTimeInstall) - { - McpLog.Info("MCP server installation completed successfully."); - } - } - catch (System.Exception ex) - { - error = ex.Message; - - // Store the error for display in the UI, but don't mark as handled - // This allows the user to manually rebuild via the "Rebuild Server" button - string errorKey = InstallErrorKeyPrefix + version; - EditorPrefs.SetString(errorKey, ex.Message ?? "Unknown error"); - - // Don't mark as installed - user needs to manually rebuild - } - - if (!string.IsNullOrEmpty(error)) - { - McpLog.Info($"Server installation failed: {error}. Use Window > MCP For Unity > Rebuild Server to retry.", always: false); - } - } - - private static string GetPackageVersion() - { - try - { - var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly( - typeof(PackageLifecycleManager).Assembly - ); - if (info != null && !string.IsNullOrEmpty(info.version)) - { - return info.version; - } - } - catch { } - - // Fallback to embedded server version - return GetEmbeddedServerVersion(); - } - - private static string GetEmbeddedServerVersion() - { - try - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) - { - var versionPath = Path.Combine(embeddedSrc, "server_version.txt"); - if (File.Exists(versionPath)) - { - return File.ReadAllText(versionPath)?.Trim() ?? "unknown"; - } - } - } - catch { } - return "unknown"; - } - - private static bool LegacyRootsExist() - { - try - { - string home = System.Environment.GetFolderPath( - System.Environment.SpecialFolder.UserProfile - ) ?? string.Empty; - - string[] legacyRoots = - { - Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), - Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") - }; - - foreach (var root in legacyRoots) - { - try - { - if (File.Exists(Path.Combine(root, "server.py"))) - { - return true; - } - } - catch { } - } - } - catch { } - return false; - } - - private static void CleanupOldVersionKeys(string currentVersion) - { - try - { - // Get all EditorPrefs keys that start with our version prefix - // Note: Unity doesn't provide a way to enumerate all keys, so we can only - // clean up known legacy keys. Future versions will be cleaned up when - // a newer version runs. - // This is a best-effort cleanup. - } - catch { } - } - - private static void CleanupLegacyPrefs() - { - try - { - // Clean up old preference keys that are no longer used - string[] legacyKeys = - { - "MCPForUnity.ServerSrc", - "MCPForUnity.PythonDirOverride", - "MCPForUnity.LegacyDetectLogged" // Old prefix without version - }; - - foreach (var key in legacyKeys) - { - try - { - if (EditorPrefs.HasKey(key)) - { - EditorPrefs.DeleteKey(key); - } - } - catch { } - } - } - catch { } - } - - /// - /// Gets the last installation error for the current package version, if any. - /// Returns null if there was no error or the error has been cleared. - /// - public static string GetLastInstallError() - { - try - { - string currentVersion = GetPackageVersion(); - string errorKey = InstallErrorKeyPrefix + currentVersion; - if (EditorPrefs.HasKey(errorKey)) - { - return EditorPrefs.GetString(errorKey, null); - } - } - catch { } - return null; - } - - /// - /// Clears the last installation error. Should be called after a successful manual rebuild. - /// - public static void ClearLastInstallError() - { - try - { - string currentVersion = GetPackageVersion(); - string errorKey = InstallErrorKeyPrefix + currentVersion; - if (EditorPrefs.HasKey(errorKey)) - { - EditorPrefs.DeleteKey(errorKey); - } - } - catch { } - } - } -} diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs index e7c4891..1de6f02 100644 --- a/MCPForUnity/Editor/Helpers/PortManager.cs +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -8,6 +8,7 @@ using System.Text; using System.Threading; using Newtonsoft.Json; using UnityEngine; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { @@ -18,7 +19,7 @@ namespace MCPForUnity.Editor.Helpers { private static bool IsDebugEnabled() { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { return false; } } @@ -35,42 +36,20 @@ namespace MCPForUnity.Editor.Helpers } /// - /// Get the port to use - either from storage or discover a new one - /// Will try stored port first, then fallback to discovering new port + /// Get the port to use from storage, or return the default if none has been saved yet. /// /// Port number to use public static int GetPortWithFallback() { - // Try to load stored port first, but only if it's from the current project var storedConfig = GetStoredPortConfig(); if (storedConfig != null && storedConfig.unity_port > 0 && - string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - IsPortAvailable(storedConfig.unity_port)) + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using stored port {storedConfig.unity_port} for current project"); return storedConfig.unity_port; } - // If stored port exists but is currently busy, wait briefly for release - if (storedConfig != null && storedConfig.unity_port > 0) - { - if (WaitForPortRelease(storedConfig.unity_port, 1500)) - { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Stored port {storedConfig.unity_port} became available after short wait"); - return storedConfig.unity_port; - } - // Port is still busy after waiting - find a new available port instead - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Stored port {storedConfig.unity_port} is occupied by another instance, finding alternative..."); - int newPort = FindAvailablePort(); - SavePort(newPort); - return newPort; - } - - // If no valid stored port, find a new one and save it - int foundPort = FindAvailablePort(); - SavePort(foundPort); - return foundPort; + return DefaultPort; } /// @@ -81,10 +60,30 @@ namespace MCPForUnity.Editor.Helpers { int newPort = FindAvailablePort(); SavePort(newPort); - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Discovered and saved new port: {newPort}"); + if (IsDebugEnabled()) McpLog.Info($"Discovered and saved new port: {newPort}"); return newPort; } + /// + /// Persist a user-selected port and return the value actually stored. + /// If is unavailable, the next available port is chosen instead. + /// + public static int SetPreferredPort(int port) + { + if (port <= 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "Port must be positive."); + } + + if (!IsPortAvailable(port)) + { + throw new InvalidOperationException($"Port {port} is already in use."); + } + + SavePort(port); + return port; + } + /// /// Find an available port starting from the default port /// @@ -94,18 +93,18 @@ namespace MCPForUnity.Editor.Helpers // Always try default port first if (IsPortAvailable(DefaultPort)) { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using default port {DefaultPort}"); + if (IsDebugEnabled()) McpLog.Info($"Using default port {DefaultPort}"); return DefaultPort; } - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Default port {DefaultPort} is in use, searching for alternative..."); + if (IsDebugEnabled()) McpLog.Info($"Default port {DefaultPort} is in use, searching for alternative..."); // Search for alternatives for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) { if (IsPortAvailable(port)) { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Found available port {port}"); + if (IsDebugEnabled()) McpLog.Info($"Found available port {port}"); return port; } } @@ -214,11 +213,11 @@ namespace MCPForUnity.Editor.Helpers string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Saved port {port} to storage"); + if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage"); } catch (Exception ex) { - Debug.LogWarning($"Could not save port to storage: {ex.Message}"); + McpLog.Warn($"Could not save port to storage: {ex.Message}"); } } @@ -250,7 +249,7 @@ namespace MCPForUnity.Editor.Helpers } catch (Exception ex) { - Debug.LogWarning($"Could not load port from storage: {ex.Message}"); + McpLog.Warn($"Could not load port from storage: {ex.Message}"); return 0; } } @@ -281,7 +280,7 @@ namespace MCPForUnity.Editor.Helpers } catch (Exception ex) { - Debug.LogWarning($"Could not load port config: {ex.Message}"); + McpLog.Warn($"Could not load port config: {ex.Message}"); return null; } } diff --git a/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs new file mode 100644 index 0000000..34a5391 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides shared utilities for deriving deterministic project identity information + /// used by transport clients (hash, name, persistent session id). + /// + [InitializeOnLoad] + internal static class ProjectIdentityUtility + { + private const string SessionPrefKey = EditorPrefKeys.SessionId; + private static bool _legacyKeyCleared; + private static string _cachedProjectName = "Unknown"; + private static string _cachedProjectHash = "default"; + private static string _fallbackSessionId; + private static bool _cacheScheduled; + + static ProjectIdentityUtility() + { + ScheduleCacheRefresh(); + EditorApplication.projectChanged += ScheduleCacheRefresh; + } + + private static void ScheduleCacheRefresh() + { + if (_cacheScheduled) + { + return; + } + + _cacheScheduled = true; + EditorApplication.delayCall += CacheIdentityOnMainThread; + } + + private static void CacheIdentityOnMainThread() + { + EditorApplication.delayCall -= CacheIdentityOnMainThread; + _cacheScheduled = false; + UpdateIdentityCache(); + } + + private static void UpdateIdentityCache() + { + try + { + string dataPath = Application.dataPath; + if (string.IsNullOrEmpty(dataPath)) + { + return; + } + + _cachedProjectHash = ComputeProjectHash(dataPath); + _cachedProjectName = ComputeProjectName(dataPath); + } + catch + { + // Ignore and keep defaults + } + } + + /// + /// Returns the SHA1 hash of the current project path (truncated to 16 characters). + /// Matches the legacy hash used by the stdio bridge and server registry. + /// + public static string GetProjectHash() + { + EnsureIdentityCache(); + return _cachedProjectHash; + } + + /// + /// Returns a human friendly project name derived from the Assets directory path, + /// or "Unknown" if the name cannot be determined. + /// + public static string GetProjectName() + { + EnsureIdentityCache(); + return _cachedProjectName; + } + + private static string ComputeProjectHash(string dataPath) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(dataPath); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(0, Math.Min(16, sb.Length)).ToLowerInvariant(); + } + catch + { + return "default"; + } + } + + private static string ComputeProjectName(string dataPath) + { + try + { + string projectPath = dataPath; + projectPath = projectPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) + { + projectPath = projectPath[..^6].TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + string name = Path.GetFileName(projectPath); + return string.IsNullOrEmpty(name) ? "Unknown" : name; + } + catch + { + return "Unknown"; + } + } + + /// + /// Persists a server-assigned session id. + /// Safe to call from background threads. + /// + public static void SetSessionId(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + EditorApplication.delayCall += () => + { + try + { + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to persist session ID: {ex.Message}"); + } + }; + } + + /// + /// Retrieves a persistent session id for the plugin, creating one if absent. + /// The session id is unique per project (scoped by project hash). + /// + public static string GetOrCreateSessionId() + { + try + { + // Make the session ID project-specific by including the project hash in the key + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + string sessionId = EditorPrefs.GetString(projectSpecificKey, string.Empty); + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString(); + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + return sessionId; + } + catch + { + // If prefs are unavailable (e.g. during batch tests) fall back to runtime guid. + if (string.IsNullOrEmpty(_fallbackSessionId)) + { + _fallbackSessionId = Guid.NewGuid().ToString(); + } + + return _fallbackSessionId; + } + } + + /// + /// Clears the persisted session id (mainly for tests). + /// + public static void ResetSessionId() + { + try + { + // Clear the project-specific session ID + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + if (EditorPrefs.HasKey(projectSpecificKey)) + { + EditorPrefs.DeleteKey(projectSpecificKey); + } + + if (!_legacyKeyCleared && EditorPrefs.HasKey(SessionPrefKey)) + { + EditorPrefs.DeleteKey(SessionPrefKey); + _legacyKeyCleared = true; + } + + _fallbackSessionId = null; + } + catch + { + // Ignore + } + } + + private static void EnsureIdentityCache() + { + // When Application.dataPath is unavailable (e.g., batch mode) we fall back to + // hashing the current working directory/Assets path so each project still + // derives a deterministic, per-project session id rather than sharing "default". + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + UpdateIdentityCache(); + + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + string fallback = TryComputeFallbackProjectHash(); + if (!string.IsNullOrEmpty(fallback)) + { + _cachedProjectHash = fallback; + } + } + + private static string TryComputeFallbackProjectHash() + { + try + { + string workingDirectory = Directory.GetCurrentDirectory(); + if (string.IsNullOrEmpty(workingDirectory)) + { + return "default"; + } + + // Normalise trailing separators so hashes remain stable + workingDirectory = workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return ComputeProjectHash(Path.Combine(workingDirectory, "Assets")); + } + catch + { + return "default"; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta rename to MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta index 38f1997..b7879e1 100644 --- a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta +++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 2c76f0c7ff138ba4a952481e04bc3974 +guid: 936e878ce1275453bae5e0cf03bd9d30 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs deleted file mode 100644 index de6167a..0000000 --- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.IO; -using System.Linq; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Services; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Automatically syncs Python tools to the MCP server when: - /// - PythonToolsAsset is modified - /// - Python files are imported/reimported - /// - Unity starts up - /// - [InitializeOnLoad] - public class PythonToolSyncProcessor : AssetPostprocessor - { - private const string SyncEnabledKey = "MCPForUnity.AutoSyncEnabled"; - private static bool _isSyncing = false; - - static PythonToolSyncProcessor() - { - // Sync on Unity startup - EditorApplication.delayCall += () => - { - if (IsAutoSyncEnabled()) - { - SyncAllTools(); - } - }; - } - - /// - /// Called after any assets are imported, deleted, or moved - /// - private static void OnPostprocessAllAssets( - string[] importedAssets, - string[] deletedAssets, - string[] movedAssets, - string[] movedFromAssetPaths) - { - // Prevent infinite loop - don't process if we're currently syncing - if (_isSyncing || !IsAutoSyncEnabled()) - return; - - bool needsSync = false; - - // Only check for .py file changes, not PythonToolsAsset changes - // (PythonToolsAsset changes are internal state updates from syncing) - foreach (string path in importedAssets.Concat(movedAssets)) - { - // Check if any .py files were modified - if (path.EndsWith(".py")) - { - needsSync = true; - break; - } - } - - // Check if any .py files were deleted - if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py"))) - { - needsSync = true; - } - - if (needsSync) - { - SyncAllTools(); - } - } - - /// - /// Syncs all Python tools from all PythonToolsAsset instances to the MCP server - /// - public static void SyncAllTools() - { - // Prevent re-entrant calls - if (_isSyncing) - { - McpLog.Warn("Sync already in progress, skipping..."); - return; - } - - _isSyncing = true; - try - { - if (!ServerPathResolver.TryFindEmbeddedServerSource(out string srcPath)) - { - McpLog.Warn("Cannot sync Python tools: MCP server source not found"); - return; - } - - string toolsDir = Path.Combine(srcPath, "tools", "custom"); - - var result = MCPServiceLocator.ToolSync.SyncProjectTools(toolsDir); - - if (result.Success) - { - if (result.CopiedCount > 0 || result.SkippedCount > 0) - { - McpLog.Info($"Python tools synced: {result.CopiedCount} copied, {result.SkippedCount} skipped"); - } - } - else - { - McpLog.Error($"Python tool sync failed with {result.ErrorCount} errors"); - foreach (var msg in result.Messages) - { - McpLog.Error($" - {msg}"); - } - } - } - catch (System.Exception ex) - { - McpLog.Error($"Python tool sync exception: {ex.Message}"); - } - finally - { - _isSyncing = false; - } - } - - /// - /// Checks if auto-sync is enabled (default: true) - /// - public static bool IsAutoSyncEnabled() - { - return EditorPrefs.GetBool(SyncEnabledKey, true); - } - - /// - /// Enables or disables auto-sync - /// - public static void SetAutoSyncEnabled(bool enabled) - { - EditorPrefs.SetBool(SyncEnabledKey, enabled); - McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}"); - } - - /// - /// Reimport all Python files in the project - /// - public static void ReimportPythonFiles() - { - // Find all Python files (imported as TextAssets by PythonFileImporter) - var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" }) - .Select(AssetDatabase.GUIDToAssetPath) - .Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - foreach (string path in pythonGuids) - { - AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); - } - - int count = pythonGuids.Length; - McpLog.Info($"Reimported {count} Python files"); - AssetDatabase.Refresh(); - } - - /// - /// Manually trigger sync - /// - public static void ManualSync() - { - McpLog.Info("Manually syncing Python tools..."); - SyncAllTools(); - } - - /// - /// Toggle auto-sync - /// - public static void ToggleAutoSync() - { - SetAutoSyncEnabled(!IsAutoSyncEnabled()); - } - - /// - /// Validate menu item (shows checkmark when enabled) - /// - public static bool ToggleAutoSyncValidate() - { - Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled()); - return true; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/Response.cs b/MCPForUnity/Editor/Helpers/Response.cs index cfcd2ef..39d7f6c 100644 --- a/MCPForUnity/Editor/Helpers/Response.cs +++ b/MCPForUnity/Editor/Helpers/Response.cs @@ -1,62 +1,108 @@ -using System; -using System.Collections.Generic; +using Newtonsoft.Json; namespace MCPForUnity.Editor.Helpers { - /// - /// Provides static methods for creating standardized success and error response objects. - /// Ensures consistent JSON structure for communication back to the Python server. - /// - public static class Response - { - /// - /// Creates a standardized success response object. - /// - /// A message describing the successful operation. - /// Optional additional data to include in the response. - /// An object representing the success response. - public static object Success(string message, object data = null) - { - if (data != null) - { - return new - { - success = true, - message = message, - data = data, - }; - } - else - { - return new { success = true, message = message }; - } - } +public interface IMcpResponse +{ + [JsonProperty("success")] + bool Success { get; } + } - /// - /// Creates a standardized error response object. - /// - /// A message describing the error. - /// Optional additional data (e.g., error details) to include. - /// An object representing the error response. - public static object Error(string errorCodeOrMessage, object data = null) + public sealed class SuccessResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("message")] + public string Message { get; } + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public SuccessResponse(string message, object data = null) { - if (data != null) - { - // Note: The key is "error" for error messages, not "message" - return new - { - success = false, - // Preserve original behavior while adding a machine-parsable code field. - // If callers pass a code string, it will be echoed in both code and error. - code = errorCodeOrMessage, - error = errorCodeOrMessage, - data = data, - }; - } - else - { - return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; - } + Message = message; + Data = data; + } + } + + public sealed class ErrorResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => false; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] + public string Code { get; } + + [JsonIgnore] + public string code => Code; + + [JsonProperty("error")] + public string Error { get; } + + [JsonIgnore] + public string error => Error; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public ErrorResponse(string messageOrCode, object data = null) + { + Code = messageOrCode; + Error = messageOrCode; + Data = data; + } + } + + public sealed class PendingResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("_mcp_status")] + public string Status => "pending"; + + [JsonIgnore] + public string _mcp_status => Status; + + [JsonProperty("_mcp_poll_interval")] + public double PollIntervalSeconds { get; } + + [JsonIgnore] + public double _mcp_poll_interval => PollIntervalSeconds; + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; } + + [JsonIgnore] + public string message => Message; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public PendingResponse(string message = "", double pollIntervalSeconds = 1.0, object data = null) + { + Message = string.IsNullOrEmpty(message) ? null : message; + PollIntervalSeconds = pollIntervalSeconds; + Data = data; } } } diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs deleted file mode 100644 index 2e149bb..0000000 --- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs +++ /dev/null @@ -1,1001 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net; -using System.Runtime.InteropServices; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ServerInstaller - { - private const string RootFolder = "UnityMCP"; - private const string ServerFolder = "UnityMcpServer"; - private const string VersionFileName = "server_version.txt"; - - /// - /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. - /// No network calls or Git operations are performed. - /// - public static void EnsureServerInstalled() - { - try - { - string saveLocation = GetSaveLocation(); - TryCreateMacSymlinkForAppSupport(); - string destRoot = Path.Combine(saveLocation, ServerFolder); - string destSrc = Path.Combine(destRoot, "src"); - - // Detect legacy installs and version state (logs) - DetectAndLogLegacyInstallStates(destRoot); - - // Resolve embedded source and versions - if (!TryGetEmbeddedServerSource(out string embeddedSrc)) - { - // Asset Store install - no embedded server - // Check if server was already downloaded - if (File.Exists(Path.Combine(destSrc, "server.py"))) - { - McpLog.Info("Using previously downloaded MCP server.", always: false); - } - else - { - McpLog.Info("MCP server not found. Download via Window > MCP For Unity > Open MCP Window.", always: false); - } - return; // Graceful exit - no exception - } - - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; - string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); - - bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); - bool needOverwrite = !destHasServer - || string.IsNullOrEmpty(installedVer) - || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); - - // Ensure destination exists - Directory.CreateDirectory(destRoot); - - if (needOverwrite) - { - // Copy the entire UnityMcpServer folder (parent of src) - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer - CopyDirectoryRecursive(embeddedRoot, destRoot); - - // Write/refresh version file - try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } - McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); - } - - // Cleanup legacy installs that are missing version or older than embedded - foreach (var legacyRoot in GetLegacyRootsForDetection()) - { - try - { - string legacySrc = Path.Combine(legacyRoot, "src"); - if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; - string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); - bool legacyOlder = string.IsNullOrEmpty(legacyVer) - || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); - if (legacyOlder) - { - TryKillUvForPath(legacySrc); - if (DeleteDirectoryWithRetry(legacyRoot)) - { - McpLog.Info($"Removed legacy server at '{legacyRoot}'."); - } - else - { - McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}' (files may be in use)"); - } - } - } - catch { } - } - - // Clear overrides that might point at legacy locations - try - { - EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); - EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); - } - catch { } - return; - } - catch (Exception ex) - { - // If a usable server is already present (installed or embedded), don't fail hard—just warn. - bool hasInstalled = false; - try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { } - - if (hasInstalled || TryGetEmbeddedServerSource(out _)) - { - McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); - return; - } - - McpLog.Error($"Failed to ensure server installation: {ex.Message}"); - } - } - - public static string GetServerPath() - { - return Path.Combine(GetSaveLocation(), ServerFolder, "src"); - } - - /// - /// Gets the platform-specific save location for the server. - /// - private static string GetSaveLocation() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Use per-user LocalApplicationData for canonical install location - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, RootFolder); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdg)) - { - xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, - ".local", "share"); - } - return Path.Combine(xdg, RootFolder); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // On macOS, use LocalApplicationData (~/Library/Application Support) - var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support - bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); - if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) - { - // Fallback: construct from $HOME - var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - localAppSupport = Path.Combine(home, "Library", "Application Support"); - } - TryCreateMacSymlinkForAppSupport(); - return Path.Combine(localAppSupport, RootFolder); - } - throw new Exception("Unsupported operating system"); - } - - /// - /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support - /// to mitigate arg parsing and quoting issues in some MCP clients. - /// Safe to call repeatedly. - /// - private static void TryCreateMacSymlinkForAppSupport() - { - try - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - if (string.IsNullOrEmpty(home)) return; - - string canonical = Path.Combine(home, "Library", "Application Support"); - string symlink = Path.Combine(home, "Library", "AppSupport"); - - // If symlink exists already, nothing to do - if (Directory.Exists(symlink) || File.Exists(symlink)) return; - - // Create symlink only if canonical exists - if (!Directory.Exists(canonical)) return; - - // Use 'ln -s' to create a directory symlink (macOS) - var psi = new ProcessStartInfo - { - FileName = "/bin/ln", - Arguments = $"-s \"{canonical}\" \"{symlink}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - p?.WaitForExit(2000); - } - catch { /* best-effort */ } - } - - private static bool IsDirectoryWritable(string path) - { - try - { - File.Create(Path.Combine(path, "test.txt")).Dispose(); - File.Delete(Path.Combine(path, "test.txt")); - return true; - } - catch - { - return false; - } - } - - /// - /// Checks if the server is installed at the specified location. - /// - private static bool IsServerInstalled(string location) - { - return Directory.Exists(location) - && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); - } - - /// - /// Detects legacy installs or older versions and logs findings (no deletion yet). - /// - private static void DetectAndLogLegacyInstallStates(string canonicalRoot) - { - try - { - string canonicalSrc = Path.Combine(canonicalRoot, "src"); - // Normalize canonical root for comparisons - string normCanonicalRoot = NormalizePathSafe(canonicalRoot); - string embeddedSrc = null; - TryGetEmbeddedServerSource(out embeddedSrc); - - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); - string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); - - // Legacy paths (macOS/Linux .config; Windows roaming as example) - foreach (var legacyRoot in GetLegacyRootsForDetection()) - { - // Skip logging for the canonical root itself - if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) - continue; - string legacySrc = Path.Combine(legacyRoot, "src"); - bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); - string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); - - if (hasServer) - { - // Case 1: No version file - if (string.IsNullOrEmpty(legacyVer)) - { - McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); - } - - // Case 2: Lives in legacy path - McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); - - // Case 3: Has version but appears older than embedded - if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) - { - McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); - } - } - } - - // Also log if canonical is missing version (treated as older) - if (Directory.Exists(canonicalRoot)) - { - if (string.IsNullOrEmpty(installedVer)) - { - McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); - } - else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) - { - McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); - } - } - } - catch (Exception ex) - { - McpLog.Warn("Detect legacy/version state failed: " + ex.Message); - } - } - - private static string NormalizePathSafe(string path) - { - try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } - catch { return path; } - } - - private static bool PathsEqualSafe(string a, string b) - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; - string na = NormalizePathSafe(a); - string nb = NormalizePathSafe(b); - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); - } - return string.Equals(na, nb, StringComparison.Ordinal); - } - catch { return false; } - } - - private static IEnumerable GetLegacyRootsForDetection() - { - var roots = new List(); - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - // macOS/Linux legacy - roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); - roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); - // Windows roaming example - try - { - string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - if (!string.IsNullOrEmpty(roaming)) - roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); - // Windows legacy: early installers/dev scripts used %LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer - // Detect this location so we can clean up older copies during install/update. - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - if (!string.IsNullOrEmpty(localAppData)) - roots.Add(Path.Combine(localAppData, "Programs", "UnityMCP", "UnityMcpServer")); - } - catch { } - return roots; - } - - /// - /// Attempts to kill UV and Python processes associated with a specific server path. - /// This is necessary on Windows because the OS blocks file deletion when processes - /// have open file handles, unlike macOS/Linux which allow unlinking open files. - /// - private static void TryKillUvForPath(string serverSrcPath) - { - try - { - if (string.IsNullOrEmpty(serverSrcPath)) return; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - KillWindowsUvProcesses(serverSrcPath); - return; - } - - // Unix: use pgrep to find processes by command line - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/pgrep", - Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - if (p == null) return; - string outp = p.StandardOutput.ReadToEnd(); - p.WaitForExit(1500); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) - { - foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) - { - if (int.TryParse(line.Trim(), out int pid)) - { - try { Process.GetProcessById(pid).Kill(); } catch { } - } - } - } - } - catch { } - } - - /// - /// Kills Windows processes running from the virtual environment directory. - /// Uses WMIC (Windows Management Instrumentation) to safely query only processes - /// with executables in the .venv path, avoiding the need to iterate all system processes. - /// This prevents accidentally killing IDE processes or other critical system processes. - /// - /// Why this is needed on Windows: - /// - Windows blocks file/directory deletion when ANY process has an open file handle - /// - UV creates a virtual environment with python.exe and other executables - /// - These processes may hold locks on DLLs, .pyd files, or the executables themselves - /// - macOS/Linux allow deletion of open files (unlink), but Windows does not - /// - private static void KillWindowsUvProcesses(string serverSrcPath) - { - try - { - if (string.IsNullOrEmpty(serverSrcPath)) return; - - string venvPath = Path.Combine(serverSrcPath, ".venv"); - if (!Directory.Exists(venvPath)) return; - - string normalizedVenvPath = Path.GetFullPath(venvPath).ToLowerInvariant(); - - // Use WMIC to find processes with executables in the .venv directory - // This is much safer than iterating all processes - var psi = new ProcessStartInfo - { - FileName = "wmic", - Arguments = $"process where \"ExecutablePath like '%{normalizedVenvPath.Replace("\\", "\\\\")}%'\" get ProcessId", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi); - if (proc == null) return; - - string output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(5000); - - if (proc.ExitCode != 0) return; - - // Parse PIDs from WMIC output - var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - string trimmed = line.Trim(); - if (trimmed.Equals("ProcessId", StringComparison.OrdinalIgnoreCase)) continue; - if (string.IsNullOrWhiteSpace(trimmed)) continue; - - if (int.TryParse(trimmed, out int pid)) - { - try - { - using var p = Process.GetProcessById(pid); - // Double-check it's not a critical process - string name = p.ProcessName.ToLowerInvariant(); - if (name == "unity" || name == "code" || name == "devenv" || name == "rider64") - { - continue; // Skip IDE processes - } - p.Kill(); - p.WaitForExit(2000); - } - catch { } - } - } - - // Give processes time to fully exit - System.Threading.Thread.Sleep(500); - } - catch { } - } - - /// - /// Attempts to delete a directory with retry logic to handle Windows file locking issues. - /// - /// Why retries are necessary on Windows: - /// - Even after killing processes, Windows may take time to release file handles - /// - Antivirus, Windows Defender, or indexing services may temporarily lock files - /// - File Explorer previews can hold locks on certain file types - /// - Readonly attributes on files (common in .venv) block deletion - /// - /// This method handles these cases by: - /// - Retrying deletion after a delay to allow handle release - /// - Clearing readonly attributes that block deletion - /// - Distinguishing between temporary locks (retry) and permanent failures - /// - private static bool DeleteDirectoryWithRetry(string path, int maxRetries = 3, int delayMs = 500) - { - for (int i = 0; i < maxRetries; i++) - { - try - { - if (!Directory.Exists(path)) return true; - - Directory.Delete(path, recursive: true); - return true; - } - catch (UnauthorizedAccessException) - { - if (i < maxRetries - 1) - { - // Wait for file handles to be released - System.Threading.Thread.Sleep(delayMs); - - // Try to clear readonly attributes - try - { - foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - try - { - var attrs = File.GetAttributes(file); - if ((attrs & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) - { - File.SetAttributes(file, attrs & ~FileAttributes.ReadOnly); - } - } - catch { } - } - } - catch { } - } - } - catch (IOException) - { - if (i < maxRetries - 1) - { - // File in use, wait and retry - System.Threading.Thread.Sleep(delayMs); - } - } - catch - { - return false; - } - } - return false; - } - - private static string ReadVersionFile(string path) - { - try - { - if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; - string v = File.ReadAllText(path).Trim(); - return string.IsNullOrEmpty(v) ? null : v; - } - catch { return null; } - } - - private static int CompareSemverSafe(string a, string b) - { - try - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; - var ap = a.Split('.'); - var bp = b.Split('.'); - for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) - { - int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; - int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; - if (ai != bi) return ai.CompareTo(bi); - } - return 0; - } - catch { return 0; } - } - - /// - /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package - /// or common development locations. - /// - private static bool TryGetEmbeddedServerSource(out string srcPath) - { - return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); - } - - private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; - - private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) - { - Directory.CreateDirectory(destinationDir); - - foreach (string filePath in Directory.GetFiles(sourceDir)) - { - string fileName = Path.GetFileName(filePath); - string destFile = Path.Combine(destinationDir, fileName); - File.Copy(filePath, destFile, overwrite: true); - } - - foreach (string dirPath in Directory.GetDirectories(sourceDir)) - { - string dirName = Path.GetFileName(dirPath); - foreach (var skip in _skipDirs) - { - if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) - goto NextDir; - } - try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } - string destSubDir = Path.Combine(destinationDir, dirName); - CopyDirectoryRecursive(dirPath, destSubDir); - NextDir:; - } - } - - public static bool RebuildMcpServer() - { - try - { - // Find embedded source - if (!TryGetEmbeddedServerSource(out string embeddedSrc)) - { - McpLog.Error("RebuildMcpServer: Could not find embedded server source."); - return false; - } - - string saveLocation = GetSaveLocation(); - string destRoot = Path.Combine(saveLocation, ServerFolder); - string destSrc = Path.Combine(destRoot, "src"); - - // Kill any running uv processes for this server - TryKillUvForPath(destSrc); - - // Delete the entire installed server directory - if (Directory.Exists(destRoot)) - { - if (!DeleteDirectoryWithRetry(destRoot, maxRetries: 5, delayMs: 1000)) - { - McpLog.Error($"Failed to delete existing server at {destRoot}. Please close any applications using the Python virtual environment and try again."); - return false; - } - McpLog.Info($"Deleted existing server at {destRoot}"); - } - - // Re-copy from embedded source - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; - Directory.CreateDirectory(destRoot); - CopyDirectoryRecursive(embeddedRoot, destRoot); - - // Write version file - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; - try - { - File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to write version file: {ex.Message}"); - } - - McpLog.Info($"Server rebuilt successfully at {destRoot} (version {embeddedVer})"); - - // Clear any previous installation error - - PackageLifecycleManager.ClearLastInstallError(); - - - return true; - } - catch (Exception ex) - { - McpLog.Error($"RebuildMcpServer failed: {ex.Message}"); - return false; - } - } - - internal static string FindUvPath() - { - // Allow user override via EditorPrefs - try - { - string overridePath = EditorPrefs.GetString("MCPForUnity.UvPath", string.Empty); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - if (ValidateUvBinary(overridePath)) return overridePath; - } - } - catch { } - - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - - // Platform-specific candidate lists - string[] candidates; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; - string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - - // Fast path: resolve from PATH first - try - { - var wherePsi = new ProcessStartInfo - { - FileName = "where", - Arguments = "uv.exe", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var wp = Process.Start(wherePsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(1500); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - string path = line.Trim(); - if (File.Exists(path) && ValidateUvBinary(path)) return path; - } - } - } - catch { } - - // Windows Store (PythonSoftwareFoundation) install location probe - // Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe - try - { - string pkgsRoot = Path.Combine(localAppData, "Packages"); - if (Directory.Exists(pkgsRoot)) - { - var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly) - .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase); - foreach (var pkg in pythonPkgs) - { - string localCache = Path.Combine(pkg, "LocalCache", "local-packages"); - if (!Directory.Exists(localCache)) continue; - var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly) - .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase); - foreach (var pyRoot in pyRoots) - { - string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe"); - if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe; - } - } - } - } - catch { } - - candidates = new[] - { - // Preferred: WinGet Links shims (stable entrypoints) - // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links) - Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), - Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), - - // Common per-user installs - Path.Combine(localAppData, @"Programs\Python\Python314\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python314\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"), - - // Program Files style installs (if a native installer was used) - Path.Combine(programFiles, @"uv\uv.exe"), - - // Try simple name resolution later via PATH - "uv.exe", - "uv" - }; - } - else - { - candidates = new[] - { - "/opt/homebrew/bin/uv", - "/usr/local/bin/uv", - "/usr/bin/uv", - "/opt/local/bin/uv", - Path.Combine(home, ".local", "bin", "uv"), - "/opt/homebrew/opt/uv/bin/uv", - // Framework Python installs - "/Library/Frameworks/Python.framework/Versions/3.14/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.11/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.10/bin/uv", - // Fallback to PATH resolution by name - "uv" - }; - } - - foreach (string c in candidates) - { - try - { - if (File.Exists(c) && ValidateUvBinary(c)) return c; - } - catch { /* ignore */ } - } - - // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier) - try - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var whichPsi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - try - { - // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string prepend = string.Join(":", new[] - { - Path.Combine(homeDir, ".local", "bin"), - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin" - }); - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); - } - catch { } - using var wp = Process.Start(whichPsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(3000); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - if (ValidateUvBinary(output)) return output; - } - } - } - catch { } - - // Manual PATH scan - try - { - string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - string[] parts = pathEnv.Split(Path.PathSeparator); - foreach (string part in parts) - { - try - { - // Check both uv and uv.exe - string candidateUv = Path.Combine(part, "uv"); - string candidateUvExe = Path.Combine(part, "uv.exe"); - if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; - if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; - } - catch { } - } - } - catch { } - - return null; - } - - private static bool ValidateUvBinary(string uvPath) - { - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode == 0) - { - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - } - catch { } - return false; - } - - /// - /// Download and install server from GitHub release (Asset Store workflow) - /// - public static bool DownloadAndInstallServer() - { - string packageVersion = AssetPathUtility.GetPackageVersion(); - if (packageVersion == "unknown") - { - McpLog.Error("Cannot determine package version for download."); - return false; - } - - string downloadUrl = $"https://github.com/CoplayDev/unity-mcp/releases/download/v{packageVersion}/mcp-for-unity-server-v{packageVersion}.zip"; - string tempZip = Path.Combine(Path.GetTempPath(), $"mcp-server-v{packageVersion}.zip"); - string destRoot = Path.Combine(GetSaveLocation(), ServerFolder); - - try - { - EditorUtility.DisplayProgressBar("MCP for Unity", "Downloading server...", 0.3f); - - // Download - using (var client = new WebClient()) - { - client.DownloadFile(downloadUrl, tempZip); - } - - EditorUtility.DisplayProgressBar("MCP for Unity", "Extracting server...", 0.7f); - - // Kill any running UV processes - string destSrc = Path.Combine(destRoot, "src"); - TryKillUvForPath(destSrc); - - // Delete old installation - if (Directory.Exists(destRoot)) - { - if (!DeleteDirectoryWithRetry(destRoot, maxRetries: 5, delayMs: 1000)) - { - McpLog.Warn($"Could not fully delete old server (files may be in use)"); - } - } - - // Extract to temp location first - string tempExtractDir = Path.Combine(Path.GetTempPath(), $"mcp-server-extract-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempExtractDir); - - try - { - ZipFile.ExtractToDirectory(tempZip, tempExtractDir); - - // The ZIP contains UnityMcpServer~ folder, find it and move its contents - string extractedServerFolder = Path.Combine(tempExtractDir, "UnityMcpServer~"); - Directory.CreateDirectory(destRoot); - CopyDirectoryRecursive(extractedServerFolder, destRoot); - } - finally - { - // Cleanup temp extraction directory - try - { - if (Directory.Exists(tempExtractDir)) - { - Directory.Delete(tempExtractDir, recursive: true); - } - } - catch (Exception ex) - { - McpLog.Warn($"Could not fully delete temp extraction directory: {ex.Message}"); - } - } - - EditorUtility.ClearProgressBar(); - McpLog.Info($"Server v{packageVersion} downloaded and installed successfully!"); - return true; - } - catch (Exception ex) - { - EditorUtility.ClearProgressBar(); - McpLog.Error($"Failed to download server: {ex.Message}"); - EditorUtility.DisplayDialog( - "Download Failed", - $"Could not download server from GitHub.\n\n{ex.Message}\n\nPlease check your internet connection or try again later.", - "OK" - ); - return false; - } - finally - { - try - { - if (File.Exists(tempZip)) File.Delete(tempZip); - } - catch (Exception ex) - { - McpLog.Warn($"Could not delete temp zip file: {ex.Message}"); - } - } - } - - /// - /// Check if the package has an embedded server (Git install vs Asset Store) - /// - public static bool HasEmbeddedServer() - { - return TryGetEmbeddedServerSource(out _); - } - - /// - /// Get the installed server version from the local installation - /// - public static string GetInstalledServerVersion() - { - try - { - string destRoot = Path.Combine(GetSaveLocation(), ServerFolder); - string versionPath = Path.Combine(destRoot, "src", VersionFileName); - if (File.Exists(versionPath)) - { - return File.ReadAllText(versionPath)?.Trim() ?? string.Empty; - } - } - catch (Exception ex) - { - McpLog.Warn($"Could not read version file: {ex.Message}"); - } - return string.Empty; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta b/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta deleted file mode 100644 index dfd9023..0000000 --- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5862c6a6d0a914f4d83224f8d039cf7b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs deleted file mode 100644 index 0e46294..0000000 --- a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.IO; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ServerPathResolver - { - /// - /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package - /// or common development locations. Returns true if found and sets srcPath to the folder - /// containing server.py. - /// - public static bool TryFindEmbeddedServerSource(out string srcPath) - { - // 1) Repo development layouts commonly used alongside this package - try - { - string projectRoot = Path.GetDirectoryName(Application.dataPath); - string[] devCandidates = - { - Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), - }; - foreach (string candidate in devCandidates) - { - string full = Path.GetFullPath(candidate); - if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) - { - srcPath = full; - return true; - } - } - } - catch { /* ignore */ } - - // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. - try - { -#if UNITY_2021_2_OR_NEWER - // Primary: the package that owns this assembly - var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); - if (owner != null) - { - if (TryResolveWithinPackage(owner, out srcPath)) - { - return true; - } - } - - // Secondary: scan all registered packages locally - foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) - { - if (TryResolveWithinPackage(p, out srcPath)) - { - return true; - } - } -#else - // Older Unity versions: use Package Manager Client.List as a fallback - var list = UnityEditor.PackageManager.Client.List(); - while (!list.IsCompleted) { } - if (list.Status == UnityEditor.PackageManager.StatusCode.Success) - { - foreach (var pkg in list.Result) - { - if (TryResolveWithinPackage(pkg, out srcPath)) - { - return true; - } - } - } -#endif - } - catch { /* ignore */ } - - // 3) Fallback to previous common install locations - try - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), - }; - foreach (string candidate in candidates) - { - if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) - { - srcPath = candidate; - return true; - } - } - } - catch { /* ignore */ } - - srcPath = null; - return false; - } - - private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) - { - const string CurrentId = "com.coplaydev.unity-mcp"; - - srcPath = null; - if (p == null || p.name != CurrentId) - { - return false; - } - - string packagePath = p.resolvedPath; - - // Preferred tilde folder (embedded but excluded from import) - string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); - if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) - { - srcPath = embeddedTilde; - return true; - } - - // Legacy non-tilde folder - string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); - if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) - { - srcPath = embedded; - return true; - } - - // Dev-linked sibling of the package folder - string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); - if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) - { - srcPath = sibling; - return true; - } - - return false; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta deleted file mode 100644 index d02df60..0000000 --- a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs index 0f43623..953fa79 100644 --- a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs +++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Threading; using UnityEngine; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnity.Editor.Helpers { @@ -11,8 +13,8 @@ namespace MCPForUnity.Editor.Helpers /// public static class TelemetryHelper { - private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; - private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; + private const string TELEMETRY_DISABLED_KEY = EditorPrefKeys.TelemetryDisabled; + private const string CUSTOMER_UUID_KEY = EditorPrefKeys.CustomerUuid; private static Action> s_sender; /// @@ -140,8 +142,8 @@ namespace MCPForUnity.Editor.Helpers { RecordEvent("bridge_startup", new Dictionary { - ["bridge_version"] = "3.0.2", - ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() + ["bridge_version"] = AssetPathUtility.GetPackageVersion(), + ["auto_connect"] = StdioBridgeHost.IsAutoConnectMode() }); } @@ -213,7 +215,7 @@ namespace MCPForUnity.Editor.Helpers { try { - return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + return UnityEditor.EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { diff --git a/MCPForUnity/Editor/Importers/PythonFileImporter.cs b/MCPForUnity/Editor/Importers/PythonFileImporter.cs deleted file mode 100644 index 8c60a1c..0000000 --- a/MCPForUnity/Editor/Importers/PythonFileImporter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using UnityEngine; -using UnityEditor.AssetImporters; -using System.IO; - -namespace MCPForUnity.Editor.Importers -{ - /// - /// Custom importer that allows Unity to recognize .py files as TextAssets. - /// This enables Python files to be selected in the Inspector and used like any other text asset. - /// - [ScriptedImporter(1, "py")] - public class PythonFileImporter : ScriptedImporter - { - public override void OnImportAsset(AssetImportContext ctx) - { - var textAsset = new TextAsset(File.ReadAllText(ctx.assetPath)); - ctx.AddObjectToAsset("main obj", textAsset); - ctx.SetMainObject(textAsset); - } - } -} diff --git a/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta b/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta deleted file mode 100644 index 7e2edb2..0000000 --- a/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: d68ef794590944f1ea7ee102c91887c7 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef index 8844892..47621bd 100644 --- a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -3,17 +3,18 @@ "rootNamespace": "MCPForUnity.Editor", "references": [ "MCPForUnity.Runtime", - "GUID:560b04d1a97f54a46a2660c3cc343a6f" + "GUID:560b04d1a97f54a46a2660c3cc343a6f" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], - "allowUnsafeCode": false, "overrideReferences": false, - "precompiledReferences": [], + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false -} \ No newline at end of file +} \ No newline at end of file diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs.meta b/MCPForUnity/Editor/MCPForUnityBridge.cs.meta deleted file mode 100644 index f8d1f46..0000000 --- a/MCPForUnity/Editor/MCPForUnityBridge.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 96dc847eb7f7a45e0b91241db934a4be -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnityMenu.cs b/MCPForUnity/Editor/MCPForUnityMenu.cs deleted file mode 100644 index 714e485..0000000 --- a/MCPForUnity/Editor/MCPForUnityMenu.cs +++ /dev/null @@ -1,75 +0,0 @@ -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Setup; -using MCPForUnity.Editor.Windows; -using UnityEditor; - -namespace MCPForUnity.Editor -{ - /// - /// Centralized menu items for MCP For Unity - /// - public static class MCPForUnityMenu - { - // ======================================== - // Main Menu Items - // ======================================== - - /// - /// Show the setup wizard - /// - [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] - public static void ShowSetupWizard() - { - SetupWizard.ShowSetupWizard(); - } - - /// - /// Open the main MCP For Unity window - /// - [MenuItem("Window/MCP For Unity/Open MCP Window %#m", priority = 2)] - public static void OpenMCPWindow() - { - MCPForUnityEditorWindow.ShowWindow(); - } - - // ======================================== - // Tool Sync Menu Items - // ======================================== - - /// - /// Reimport all Python files in the project - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)] - public static void ReimportPythonFiles() - { - PythonToolSyncProcessor.ReimportPythonFiles(); - } - - /// - /// Manually sync Python tools to the MCP server - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)] - public static void SyncPythonTools() - { - PythonToolSyncProcessor.ManualSync(); - } - - /// - /// Toggle auto-sync for Python tools - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)] - public static void ToggleAutoSync() - { - PythonToolSyncProcessor.ToggleAutoSync(); - } - - /// - /// Validate menu item (shows checkmark when auto-sync is enabled) - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)] - public static bool ToggleAutoSyncValidate() - { - return PythonToolSyncProcessor.ToggleAutoSyncValidate(); - } - } -} diff --git a/TestProjects/UnityMCPTests/Assets/Temp.meta b/MCPForUnity/Editor/MenuItems.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Temp.meta rename to MCPForUnity/Editor/MenuItems.meta index 30148f2..ad5fb5e 100644 --- a/TestProjects/UnityMCPTests/Assets/Temp.meta +++ b/MCPForUnity/Editor/MenuItems.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 02a6714b521ec47868512a8db433975c +guid: 9e7f37616736f4d3cbd8bdbc626f5ab9 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs new file mode 100644 index 0000000..32fde72 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -0,0 +1,46 @@ +using MCPForUnity.Editor.Setup; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.MenuItems +{ + /// + /// Centralized menu items for MCP For Unity + /// + public static class MCPForUnityMenu + { + // ======================================== + // Main Menu Items + // ======================================== + + /// + /// Show the Setup Window + /// + [MenuItem("Window/MCP For Unity/Setup Window", priority = 1)] + public static void ShowSetupWindow() + { + SetupWindowService.ShowSetupWindow(); + } + + /// + /// Toggle the main MCP For Unity window + /// + [MenuItem("Window/MCP For Unity/Toggle MCP Window %#m", priority = 2)] + public static void ToggleMCPWindow() + { + if (EditorWindow.HasOpenInstances()) + { + foreach (var window in UnityEngine.Resources.FindObjectsOfTypeAll()) + { + window.Close(); + } + } + else + { + MCPForUnityEditorWindow.ShowWindow(); + } + } + + } +} diff --git a/MCPForUnity/Editor/MCPForUnityMenu.cs.meta b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta similarity index 100% rename from MCPForUnity/Editor/MCPForUnityMenu.cs.meta rename to MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta b/MCPForUnity/Editor/Migrations.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta rename to MCPForUnity/Editor/Migrations.meta index 16c8bb6..62d67f0 100644 --- a/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta +++ b/MCPForUnity/Editor/Migrations.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0c392d9059b864f608a4d32e4347c3d6 +guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs new file mode 100644 index 0000000..186f62f --- /dev/null +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs @@ -0,0 +1,71 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Detects legacy embedded-server preferences and migrates configs to the new uvx/stdio path once. + /// + [InitializeOnLoad] + internal static class LegacyServerSrcMigration + { + private const string ServerSrcKey = EditorPrefKeys.ServerSrc; + private const string UseEmbeddedKey = EditorPrefKeys.UseEmbeddedServer; + + static LegacyServerSrcMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + bool hasServerSrc = EditorPrefs.HasKey(ServerSrcKey); + bool hasUseEmbedded = EditorPrefs.HasKey(UseEmbeddedKey); + + if (!hasServerSrc && !hasUseEmbedded) + { + return; + } + + try + { + McpLog.Info("Detected legacy embedded MCP server configuration. Updating all client configs..."); + + var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients(); + + if (summary.FailureCount > 0 || summary.SuccessCount == 0) + { + McpLog.Warn($"Legacy configuration migration incomplete ({summary.GetSummaryMessage()}). Will retry next session."); + return; + } + + if (hasServerSrc) + { + EditorPrefs.DeleteKey(ServerSrcKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.ServerSrc"); + } + + if (hasUseEmbedded) + { + EditorPrefs.DeleteKey(UseEmbeddedKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.UseEmbeddedServer"); + } + + McpLog.Info($"Legacy configuration migration complete ({summary.GetSummaryMessage()})"); + } + catch (Exception ex) + { + McpLog.Error($"Legacy MCP server migration failed: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta new file mode 100644 index 0000000..ddc85c0 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4436b2149abf4b0d8014f81cd29a2bd0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs new file mode 100644 index 0000000..9f43734 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Constants; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Keeps stdio MCP clients in sync with the current package version by rewriting their configs when the package updates. + /// + [InitializeOnLoad] + internal static class StdIoVersionMigration + { + private const string LastUpgradeKey = EditorPrefKeys.LastStdIoUpgradeVersion; + + static StdIoVersionMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + string currentVersion = AssetPathUtility.GetPackageVersion(); + if (string.IsNullOrEmpty(currentVersion) || string.Equals(currentVersion, "unknown", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string lastUpgradeVersion = string.Empty; + try { lastUpgradeVersion = EditorPrefs.GetString(LastUpgradeKey, string.Empty); } catch { } + + if (string.Equals(lastUpgradeVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return; // Already refreshed for this package version + } + + bool hadFailures = false; + bool touchedAny = false; + + var clients = new McpClients().clients; + foreach (var client in clients) + { + try + { + if (!ConfigUsesStdIo(client)) + continue; + + MCPServiceLocator.Client.ConfigureClient(client); + touchedAny = true; + } + catch (Exception ex) + { + hadFailures = true; + McpLog.Warn($"Failed to refresh stdio config for {client.name}: {ex.Message}"); + } + } + + if (!touchedAny) + { + // Nothing needed refreshing; still record version so we don't rerun every launch + try { EditorPrefs.SetString(LastUpgradeKey, currentVersion); } catch { } + return; + } + + if (hadFailures) + { + McpLog.Warn("Stdio MCP upgrade encountered errors; will retry next session."); + return; + } + + try + { + EditorPrefs.SetString(LastUpgradeKey, currentVersion); + } + catch { } + + McpLog.Info($"Updated stdio MCP configs to package version {currentVersion}."); + } + + private static bool ConfigUsesStdIo(McpClient client) + { + switch (client.mcpType) + { + case McpTypes.Codex: + return CodexConfigUsesStdIo(client); + default: + return JsonConfigUsesStdIo(client); + } + } + + private static bool JsonConfigUsesStdIo(McpClient client) + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return false; + } + + try + { + var root = JObject.Parse(File.ReadAllText(configPath)); + + JToken unityNode = null; + if (client.mcpType == McpTypes.VSCode) + { + unityNode = root.SelectToken("servers.unityMCP") + ?? root.SelectToken("mcp.servers.unityMCP"); + } + else + { + unityNode = root.SelectToken("mcpServers.unityMCP"); + } + + if (unityNode == null) return false; + + return unityNode["command"] != null; + } + catch + { + return false; + } + } + + private static bool CodexConfigUsesStdIo(McpClient client) + { + try + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return false; + } + + string toml = File.ReadAllText(configPath); + return CodexConfigHelper.TryParseCodexServer(toml, out var command, out _) + && !string.IsNullOrEmpty(command); + } + catch + { + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta new file mode 100644 index 0000000..872a357 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1d589c8c8684e6f919ffb393c4b4db5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs index 0a3fa86..13a5564 100644 --- a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs +++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs @@ -40,11 +40,11 @@ namespace MCPForUnity.Editor.Resources.Editor } }; - return Response.Success("Retrieved active tool information.", toolInfo); + return new SuccessResponse("Retrieved active tool information.", toolInfo); } catch (Exception e) { - return Response.Error($"Error getting active tool: {e.Message}"); + return new ErrorResponse($"Error getting active tool: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs b/MCPForUnity/Editor/Resources/Editor/EditorState.cs index fdcff7e..57f70f7 100644 --- a/MCPForUnity/Editor/Resources/Editor/EditorState.cs +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs @@ -28,12 +28,12 @@ namespace MCPForUnity.Editor.Resources.Editor selectionCount = UnityEditor.Selection.count, activeObjectName = UnityEditor.Selection.activeObject?.name }; - - return Response.Success("Retrieved editor state.", state); + + return new SuccessResponse("Retrieved editor state.", state); } catch (Exception e) { - return Response.Error($"Error getting editor state: {e.Message}"); + return new ErrorResponse($"Error getting editor state: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs index 2f66a01..ee47d6f 100644 --- a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs +++ b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs @@ -16,10 +16,10 @@ namespace MCPForUnity.Editor.Resources.Editor try { var stage = PrefabStageUtility.GetCurrentPrefabStage(); - + if (stage == null) { - return Response.Success("No prefab stage is currently open.", new { isOpen = false }); + return new SuccessResponse("No prefab stage is currently open.", new { isOpen = false }); } var stageInfo = new @@ -31,11 +31,11 @@ namespace MCPForUnity.Editor.Resources.Editor isDirty = stage.scene.isDirty }; - return Response.Success("Prefab stage info retrieved.", stageInfo); + return new SuccessResponse("Prefab stage info retrieved.", stageInfo); } catch (Exception e) { - return Response.Error($"Error getting prefab stage info: {e.Message}"); + return new ErrorResponse($"Error getting prefab stage info: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs b/MCPForUnity/Editor/Resources/Editor/Selection.cs index 07bb34d..022d9c4 100644 --- a/MCPForUnity/Editor/Resources/Editor/Selection.cs +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs @@ -41,11 +41,11 @@ namespace MCPForUnity.Editor.Resources.Editor assetGUIDs = UnityEditor.Selection.assetGUIDs }; - return Response.Success("Retrieved current selection details.", selectionInfo); + return new SuccessResponse("Retrieved current selection details.", selectionInfo); } catch (Exception e) { - return Response.Error($"Error getting selection: {e.Message}"); + return new ErrorResponse($"Error getting selection: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/Windows.cs b/MCPForUnity/Editor/Resources/Editor/Windows.cs index a637c1e..5719088 100644 --- a/MCPForUnity/Editor/Resources/Editor/Windows.cs +++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs @@ -48,11 +48,11 @@ namespace MCPForUnity.Editor.Resources.Editor } } - return Response.Success("Retrieved list of open editor windows.", openWindows); + return new SuccessResponse("Retrieved list of open editor windows.", openWindows); } catch (Exception e) { - return Response.Error($"Error getting editor windows: {e.Message}"); + return new ErrorResponse($"Error getting editor windows: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs index c554be2..f6a8428 100644 --- a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs +++ b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs @@ -33,7 +33,7 @@ namespace MCPForUnity.Editor.Resources.MenuItems } string message = $"Retrieved {items.Count} menu items"; - return Response.Success(message, items); + return new SuccessResponse(message, items); } internal static List GetMenuItemsInternal(bool forceRefresh) diff --git a/MCPForUnity/Editor/Resources/Project/Layers.cs b/MCPForUnity/Editor/Resources/Project/Layers.cs index eb7f1a3..9e9ef7d 100644 --- a/MCPForUnity/Editor/Resources/Project/Layers.cs +++ b/MCPForUnity/Editor/Resources/Project/Layers.cs @@ -27,12 +27,12 @@ namespace MCPForUnity.Editor.Resources.Project layers.Add(i, layerName); } } - - return Response.Success("Retrieved current named layers.", layers); + + return new SuccessResponse("Retrieved current named layers.", layers); } catch (Exception e) { - return Response.Error($"Failed to retrieve layers: {e.Message}"); + return new ErrorResponse($"Failed to retrieve layers: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs index 3306983..6e6d12f 100644 --- a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs +++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs @@ -20,7 +20,7 @@ namespace MCPForUnity.Editor.Resources.Project string assetsPath = Application.dataPath.Replace('\\', '/'); string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); string projectName = Path.GetFileName(projectRoot); - + var info = new { projectRoot = projectRoot ?? "", @@ -29,12 +29,12 @@ namespace MCPForUnity.Editor.Resources.Project platform = EditorUserBuildSettings.activeBuildTarget.ToString(), assetsPath = assetsPath }; - - return Response.Success("Retrieved project info.", info); + + return new SuccessResponse("Retrieved project info.", info); } catch (Exception e) { - return Response.Error($"Error getting project info: {e.Message}"); + return new ErrorResponse($"Error getting project info: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Project/Tags.cs b/MCPForUnity/Editor/Resources/Project/Tags.cs index 665e8d7..756f00d 100644 --- a/MCPForUnity/Editor/Resources/Project/Tags.cs +++ b/MCPForUnity/Editor/Resources/Project/Tags.cs @@ -16,11 +16,11 @@ namespace MCPForUnity.Editor.Resources.Project try { string[] tags = InternalEditorUtility.tags; - return Response.Success("Retrieved current tags.", tags); + return new SuccessResponse("Retrieved current tags.", tags); } catch (Exception e) { - return Response.Error($"Failed to retrieve tags: {e.Message}"); + return new ErrorResponse($"Failed to retrieve tags: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Tests/GetTests.cs b/MCPForUnity/Editor/Resources/Tests/GetTests.cs index 3efb1c6..f7eeda9 100644 --- a/MCPForUnity/Editor/Resources/Tests/GetTests.cs +++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs @@ -27,12 +27,12 @@ namespace MCPForUnity.Editor.Resources.Tests catch (Exception ex) { McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); - return Response.Error("Failed to retrieve tests"); + return new ErrorResponse("Failed to retrieve tests"); } string message = $"Retrieved {result.Count} tests"; - return Response.Success(message, result); + return new SuccessResponse(message, result); } } @@ -49,12 +49,12 @@ namespace MCPForUnity.Editor.Resources.Tests string modeStr = @params["mode"]?.ToString(); if (string.IsNullOrEmpty(modeStr)) { - return Response.Error("'mode' parameter is required"); + return new ErrorResponse("'mode' parameter is required"); } if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) { - return Response.Error(parseError); + return new ErrorResponse(parseError); } McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}"); @@ -66,11 +66,11 @@ namespace MCPForUnity.Editor.Resources.Tests catch (Exception ex) { McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); - return Response.Error("Failed to retrieve tests"); + return new ErrorResponse("Failed to retrieve tests"); } string message = $"Retrieved {result.Count} {parsedMode.Value} tests"; - return Response.Success(message, result); + return new SuccessResponse(message, result); } } diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs index a462e68..c67efd1 100644 --- a/MCPForUnity/Editor/Services/BridgeControlService.cs +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -1,174 +1,130 @@ + using System; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Text; +using System.Threading.Tasks; +using UnityEditor; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnity.Editor.Services { /// - /// Implementation of bridge control service + /// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio). /// public class BridgeControlService : IBridgeControlService { - public bool IsRunning => MCPForUnityBridge.IsRunning; - public int CurrentPort => MCPForUnityBridge.GetCurrentPort(); - public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode(); + private readonly TransportManager _transportManager; + private TransportMode _preferredMode = TransportMode.Http; - public void Start() + public BridgeControlService() { - // If server is installed, use auto-connect mode - // Otherwise use standard mode - string serverPath = MCPServiceLocator.Paths.GetMcpServerPath(); - if (!string.IsNullOrEmpty(serverPath) && File.Exists(Path.Combine(serverPath, "server.py"))) + _transportManager = MCPServiceLocator.TransportManager; + } + + private TransportMode ResolvePreferredMode() + { + bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + _preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio; + return _preferredMode; + } + + private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null) + { + bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true); + string transportLabel = string.IsNullOrWhiteSpace(state.TransportName) + ? mode.ToString().ToLowerInvariant() + : state.TransportName; + string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]"; + string message = messageOverride + ?? state.Error + ?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}"); + + return new BridgeVerificationResult { - MCPForUnityBridge.StartAutoConnect(); - } - else + Success = pingSucceeded && handshakeValid, + HandshakeValid = handshakeValid, + PingSucceeded = pingSucceeded, + Message = message + }; + } + + public bool IsRunning => _transportManager.GetState().IsConnected; + + public int CurrentPort + { + get { - MCPForUnityBridge.Start(); + var state = _transportManager.GetState(); + if (state.Port.HasValue) + { + return state.Port.Value; + } + + // Legacy fallback while the stdio bridge is still in play + return StdioBridgeHost.GetCurrentPort(); } } - public void Stop() + public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode(); + public TransportMode? ActiveMode => _transportManager.ActiveMode; + + public async Task StartAsync() { - MCPForUnityBridge.Stop(); + var mode = ResolvePreferredMode(); + try + { + bool started = await _transportManager.StartAsync(mode); + if (!started) + { + McpLog.Warn($"Failed to start MCP transport: {mode}"); + } + return started; + } + catch (Exception ex) + { + McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}"); + return false; + } + } + + public async Task StopAsync() + { + try + { + await _transportManager.StopAsync(); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping MCP transport: {ex.Message}"); + } + } + + public async Task VerifyAsync() + { + var mode = _transportManager.ActiveMode ?? ResolvePreferredMode(); + bool pingSucceeded = await _transportManager.VerifyAsync(); + var state = _transportManager.GetState(); + return BuildVerificationResult(state, mode, pingSucceeded); } public BridgeVerificationResult Verify(int port) { - var result = new BridgeVerificationResult + var mode = _transportManager.ActiveMode ?? ResolvePreferredMode(); + bool pingSucceeded = _transportManager.VerifyAsync().GetAwaiter().GetResult(); + var state = _transportManager.GetState(); + + if (mode == TransportMode.Stdio) { - Success = false, - HandshakeValid = false, - PingSucceeded = false, - Message = "Verification not started" - }; - - const int ConnectTimeoutMs = 1000; - const int FrameTimeoutMs = 30000; // Match bridge frame I/O timeout - - try - { - using (var client = new TcpClient()) - { - // Attempt connection - var connectTask = client.ConnectAsync(IPAddress.Loopback, port); - if (!connectTask.Wait(ConnectTimeoutMs)) - { - result.Message = "Connection timeout"; - return result; - } - - using (var stream = client.GetStream()) - { - try { client.NoDelay = true; } catch { } - - // 1) Read handshake line (ASCII, newline-terminated) - string handshake = ReadLineAscii(stream, 2000); - if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) - { - result.Message = "Bridge handshake missing FRAMING=1"; - return result; - } - - result.HandshakeValid = true; - - // 2) Send framed "ping" - byte[] payload = Encoding.UTF8.GetBytes("ping"); - WriteFrame(stream, payload, FrameTimeoutMs); - - // 3) Read framed response and check for pong - string response = ReadFrameUtf8(stream, FrameTimeoutMs); - if (!string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0) - { - result.PingSucceeded = true; - result.Success = true; - result.Message = "Bridge verified successfully"; - } - else - { - result.Message = $"Ping failed; response='{response}'"; - } - } - } - } - catch (Exception ex) - { - result.Message = $"Verification error: {ex.Message}"; + bool handshakeValid = state.IsConnected && port == CurrentPort; + string message = handshakeValid + ? $"STDIO transport listening on port {CurrentPort}" + : $"STDIO transport port mismatch (expected {CurrentPort}, got {port})"; + return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid); } - return result; + return BuildVerificationResult(state, mode, pingSucceeded); } - // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts - private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) - { - if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); - - byte[] header = new byte[8]; - ulong len = (ulong)payload.LongLength; - header[0] = (byte)(len >> 56); - header[1] = (byte)(len >> 48); - header[2] = (byte)(len >> 40); - header[3] = (byte)(len >> 32); - header[4] = (byte)(len >> 24); - header[5] = (byte)(len >> 16); - header[6] = (byte)(len >> 8); - header[7] = (byte)(len); - - stream.WriteTimeout = timeoutMs; - stream.Write(header, 0, header.Length); - stream.Write(payload, 0, payload.Length); - } - - private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) - { - byte[] header = ReadExact(stream, 8, timeoutMs); - ulong len = ((ulong)header[0] << 56) - | ((ulong)header[1] << 48) - | ((ulong)header[2] << 40) - | ((ulong)header[3] << 32) - | ((ulong)header[4] << 24) - | ((ulong)header[5] << 16) - | ((ulong)header[6] << 8) - | header[7]; - if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); - if (len > int.MaxValue) throw new IOException("Frame too large"); - byte[] payload = ReadExact(stream, (int)len, timeoutMs); - return Encoding.UTF8.GetString(payload); - } - - private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) - { - byte[] buffer = new byte[count]; - int offset = 0; - stream.ReadTimeout = timeoutMs; - while (offset < count) - { - int read = stream.Read(buffer, offset, count - offset); - if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); - offset += read; - } - return buffer; - } - - private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) - { - stream.ReadTimeout = timeoutMs; - using (var ms = new MemoryStream()) - { - byte[] one = new byte[1]; - while (ms.Length < maxLen) - { - int n = stream.Read(one, 0, 1); - if (n <= 0) break; - if (one[0] == (byte)'\n') break; - ms.WriteByte(one[0]); - } - return Encoding.ASCII.GetString(ms.ToArray()); - } - } } } diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs index 8a9c4ca..546ea38 100644 --- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -2,10 +2,12 @@ using System; using System.IO; using System.Linq; using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; @@ -20,38 +22,24 @@ namespace MCPForUnity.Editor.Services public void ConfigureClient(McpClient client) { - try + var pathService = MCPServiceLocator.Paths; + string uvxPath = pathService.GetUvxPath(); + + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); + + string result = client.mcpType == McpTypes.Codex + ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) + : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); + + if (result == "Configured successfully") { - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); - - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); - - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - throw new InvalidOperationException("Server not found. Please use manual configuration or set server path in Advanced Settings."); - } - - string result = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); - - if (result == "Configured successfully") - { - client.SetStatus(McpStatus.Configured); - Debug.Log($"MCP-FOR-UNITY: {client.name} configured successfully"); - } - else - { - Debug.LogWarning($"Configuration completed with message: {result}"); - } - - CheckClientStatus(client); + client.SetStatus(McpStatus.Configured); } - catch (Exception ex) + else { - Debug.LogError($"Failed to configure {client.name}: {ex.Message}"); - throw; + client.SetStatus(McpStatus.NotConfigured); + throw new InvalidOperationException($"Configuration failed: {result}"); } } @@ -64,14 +52,8 @@ namespace MCPForUnity.Editor.Services { try { - // Skip if already configured + // Always re-run configuration so core fields stay current CheckClientStatus(client, attemptAutoRewrite: false); - if (client.status == McpStatus.Configured) - { - summary.SkippedCount++; - summary.Messages.Add($"✓ {client.name}: Already configured"); - continue; - } // Check if required tools are available if (client.mcpType == McpTypes.ClaudeCode) @@ -83,20 +65,14 @@ namespace MCPForUnity.Editor.Services continue; } + // Force a fresh registration so transport settings stay current + UnregisterClaudeCode(); RegisterClaudeCode(); summary.SuccessCount++; - summary.Messages.Add($"✓ {client.name}: Registered successfully"); + summary.Messages.Add($"✓ {client.name}: Re-registered successfully"); } else { - // Other clients require UV - if (!pathService.IsUvDetected()) - { - summary.SkippedCount++; - summary.Messages.Add($"➜ {client.name}: UV not found"); - continue; - } - ConfigureClient(client); summary.SuccessCount++; summary.Messages.Add($"✓ {client.name}: Configured successfully"); @@ -134,32 +110,45 @@ namespace MCPForUnity.Editor.Services } string configJson = File.ReadAllText(configPath); - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); - // Check configuration based on client type string[] args = null; + string configuredUrl = null; bool configExists = false; switch (client.mcpType) { case McpTypes.VSCode: - dynamic vsConfig = JsonConvert.DeserializeObject(configJson); - if (vsConfig?.servers?.unityMCP != null) + var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; + if (vsConfig != null) { - args = vsConfig.servers.unityMCP.args.ToObject(); - configExists = true; - } - else if (vsConfig?.mcp?.servers?.unityMCP != null) - { - args = vsConfig.mcp.servers.unityMCP.args.ToObject(); - configExists = true; + var unityToken = + vsConfig["servers"]?["unityMCP"] + ?? vsConfig["mcp"]?["servers"]?["unityMCP"]; + + if (unityToken is JObject unityObj) + { + configExists = true; + + var argsToken = unityObj["args"]; + if (argsToken is JArray) + { + args = argsToken.ToObject(); + } + + var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; + if (urlToken != null && urlToken.Type != JTokenType.Null) + { + configuredUrl = urlToken.ToString(); + } + } } break; case McpTypes.Codex: - if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) + if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs, out var codexUrl)) { args = codexArgs; + configuredUrl = codexUrl; configExists = true; } break; @@ -176,9 +165,20 @@ namespace MCPForUnity.Editor.Services if (configExists) { - string configuredDir = McpConfigurationHelper.ExtractDirectoryArg(args); - bool matches = !string.IsNullOrEmpty(configuredDir) && - McpConfigurationHelper.PathsEqual(configuredDir, pythonDir); + bool matches = false; + + if (args != null && args.Length > 0) + { + string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configuredUvxUrl) && + McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + matches = UrlsEqual(configuredUrl, expectedUrl); + } if (matches) { @@ -190,15 +190,18 @@ namespace MCPForUnity.Editor.Services try { string rewriteResult = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) + : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); if (rewriteResult == "Configured successfully") { - bool debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + bool debugLogsEnabled = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); if (debugLogsEnabled) { - McpLog.Info($"Auto-updated MCP config for '{client.name}' to new path: {pythonDir}", always: false); + string targetDescriptor = args != null && args.Length > 0 + ? AssetPathUtility.GetMcpServerGitUrl() + : HttpEndpointUtility.GetMcpRpcUrl(); + McpLog.Info($"Auto-updated MCP config for '{client.name}' to new version: {targetDescriptor}", always: false); } client.SetStatus(McpStatus.Configured); } @@ -233,21 +236,29 @@ namespace MCPForUnity.Editor.Services public void RegisterClaudeCode() { var pathService = MCPServiceLocator.Paths; - string pythonDir = pathService.GetMcpServerPath(); - - if (string.IsNullOrEmpty(pythonDir)) - { - throw new InvalidOperationException("Cannot register: Python directory not found"); - } - string claudePath = pathService.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } - string uvPath = pathService.GetUvPath() ?? "uv"; - string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + string args; + if (useHttpTransport) + { + // HTTP mode: Use --transport http with URL + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + else + { + // Stdio mode: Use command with uvx + var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; + } + string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = null; @@ -278,7 +289,7 @@ namespace MCPForUnity.Editor.Services string combined = ($"{stdout}\n{stderr}") ?? string.Empty; if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) { - Debug.Log("MCP-FOR-UNITY: MCP for Unity already registered with Claude Code."); + McpLog.Info("MCP for Unity already registered with Claude Code."); } else { @@ -287,7 +298,7 @@ namespace MCPForUnity.Editor.Services return; } - Debug.Log("MCP-FOR-UNITY: Successfully registered with Claude Code."); + McpLog.Info("Successfully registered with Claude Code."); // Update status var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); @@ -301,7 +312,7 @@ namespace MCPForUnity.Editor.Services { var pathService = MCPServiceLocator.Paths; string claudePath = pathService.GetClaudeCliPath(); - + if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); @@ -323,14 +334,14 @@ namespace MCPForUnity.Editor.Services { claudeClient.SetStatus(McpStatus.NotConfigured); } - Debug.Log("MCP-FOR-UNITY: No MCP for Unity server found - already unregistered."); + McpLog.Info("No MCP for Unity server found - already unregistered."); return; } // Remove the server if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) { - Debug.Log("MCP-FOR-UNITY: MCP server successfully unregistered from Claude Code."); + McpLog.Info("MCP server successfully unregistered from Claude Code."); } else { @@ -366,19 +377,32 @@ namespace MCPForUnity.Editor.Services public string GenerateConfigJson(McpClient client) { - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); - string uvPath = MCPServiceLocator.Paths.GetUvPath(); + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); // Claude Code uses CLI commands, not JSON config if (client.mcpType == McpTypes.ClaudeCode) { - if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) - { - return "# Error: Configuration not available - check paths in Advanced Settings"; - } + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - // Show the actual command that RegisterClaudeCode() uses - string registerCommand = $"claude mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; + string registerCommand; + if (useHttpTransport) + { + // HTTP mode + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + registerCommand = $"claude mcp add --transport http UnityMCP {httpUrl}"; + } + else + { + // Stdio mode + if (string.IsNullOrEmpty(uvxPath)) + { + return "# Error: Configuration not available - check paths in Advanced Settings"; + } + + string gitUrl = AssetPathUtility.GetMcpServerGitUrl(); + registerCommand = $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity"; + } return "# Register the MCP server with Claude Code:\n" + $"{registerCommand}\n\n" + @@ -388,19 +412,18 @@ namespace MCPForUnity.Editor.Services "claude mcp list # Only works when claude is run in the project's directory"; } - if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) + if (string.IsNullOrEmpty(uvxPath)) return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }"; try { if (client.mcpType == McpTypes.Codex) { - return CodexConfigHelper.BuildCodexServerBlock(uvPath, - McpConfigurationHelper.ResolveServerDirectory(pythonDir, null)); + return CodexConfigHelper.BuildCodexServerBlock(uvxPath); } else { - return ConfigJsonBuilder.BuildManualConfigJson(uvPath, pythonDir, client); + return ConfigJsonBuilder.BuildManualConfigJson(uvxPath, client); } } catch (Exception ex) @@ -479,22 +502,46 @@ namespace MCPForUnity.Editor.Services { try { - string configPath = McpConfigurationHelper.GetClientConfigPath(client); + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); - if (!File.Exists(configPath)) + if (string.IsNullOrEmpty(claudePath)) { - client.SetStatus(McpStatus.NotConfigured); + client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); return; } - string configJson = File.ReadAllText(configPath); - dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); + // Use 'claude mcp list' to check if UnityMCP is registered + string args = "mcp list"; + string projectDir = Path.GetDirectoryName(Application.dataPath); - if (claudeConfig?.mcpServers != null) + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) { - var servers = claudeConfig.mcpServers; - // Only check for UnityMCP (fixed - removed candidate hacks) - if (servers.UnityMCP != null) + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + // Add the directory containing Claude CLI to PATH + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } + + if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 10000, pathPrepend)) + { + // Check if UnityMCP is in the output + if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) { client.SetStatus(McpStatus.Configured); return; @@ -508,5 +555,28 @@ namespace MCPForUnity.Editor.Services client.SetStatus(McpStatus.Error, ex.Message); } } + + private static bool UrlsEqual(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + { + return false; + } + + if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && + Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) + { + return Uri.Compare( + uriA, + uriB, + UriComponents.HttpRequestUrl, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + string Normalize(string value) => value.Trim().TrimEnd('/'); + + return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); + } } } diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs new file mode 100644 index 0000000..16b8bd8 --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs @@ -0,0 +1,143 @@ +using System; +using System.Threading.Tasks; +using UnityEditor; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Windows; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge. + /// + [InitializeOnLoad] + internal static class HttpBridgeReloadHandler + { + static HttpBridgeReloadHandler() + { + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + private static void OnBeforeAssemblyReload() + { + try + { + var bridge = MCPServiceLocator.Bridge; + bool shouldResume = bridge.IsRunning && bridge.ActiveMode == TransportMode.Http; + + if (shouldResume) + { + EditorPrefs.SetBool(EditorPrefKeys.ResumeHttpAfterReload, true); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); + } + + if (bridge.IsRunning) + { + var stopTask = bridge.StopAsync(); + stopTask.ContinueWith(t => + { + if (t.IsFaulted && t.Exception != null) + { + McpLog.Warn($"Error stopping MCP bridge before reload: {t.Exception.GetBaseException().Message}"); + } + }, TaskScheduler.Default); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}"); + } + } + + private static void OnAfterAssemblyReload() + { + bool resume = false; + try + { + resume = EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false); + if (resume) + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}"); + resume = false; + } + + if (!resume) + { + return; + } + + // If the editor is not compiling, attempt an immediate restart without relying on editor focus. + bool isCompiling = EditorApplication.isCompiling; + try + { + var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) isCompiling |= (bool)prop.GetValue(null); + } + catch { } + + if (!isCompiling) + { + try + { + var startTask = MCPServiceLocator.Bridge.StartAsync(); + startTask.ContinueWith(t => + { + if (t.IsFaulted) + { + var baseEx = t.Exception?.GetBaseException(); + McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {baseEx?.Message}"); + return; + } + bool started = t.Result; + if (!started) + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + else + { + MCPForUnityEditorWindow.RequestHealthVerification(); + } + }, TaskScheduler.Default); + return; + } + catch (Exception ex) + { + McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}"); + return; + } + } + + // Fallback when compiling: schedule on the editor loop + EditorApplication.delayCall += async () => + { + try + { + bool started = await MCPServiceLocator.Bridge.StartAsync(); + if (!started) + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + else + { + MCPForUnityEditorWindow.RequestHealthVerification(); + } + } + catch (Exception ex) + { + McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}"); + } + }; + } + } +} diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta new file mode 100644 index 0000000..ae5e9ed --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c0cf970a7b494a659be151dc0124296 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IBridgeControlService.cs b/MCPForUnity/Editor/Services/IBridgeControlService.cs index 6233ed7..7cc593e 100644 --- a/MCPForUnity/Editor/Services/IBridgeControlService.cs +++ b/MCPForUnity/Editor/Services/IBridgeControlService.cs @@ -1,3 +1,6 @@ +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.Transport; + namespace MCPForUnity.Editor.Services { /// @@ -9,35 +12,48 @@ namespace MCPForUnity.Editor.Services /// Gets whether the bridge is currently running /// bool IsRunning { get; } - + /// /// Gets the current port the bridge is listening on /// int CurrentPort { get; } - + /// /// Gets whether the bridge is in auto-connect mode /// bool IsAutoConnectMode { get; } - + /// - /// Starts the MCP for Unity Bridge + /// Gets the currently active transport mode, if any /// - void Start(); - + TransportMode? ActiveMode { get; } + /// - /// Stops the MCP for Unity Bridge + /// Starts the MCP for Unity Bridge asynchronously /// - void Stop(); - + /// True if the bridge started successfully + Task StartAsync(); + + /// + /// Stops the MCP for Unity Bridge asynchronously + /// + Task StopAsync(); + /// /// Verifies the bridge connection by sending a ping and waiting for a pong response /// /// The port to verify /// Verification result with detailed status BridgeVerificationResult Verify(int port); + + /// + /// Verifies the connection asynchronously (works for both HTTP and stdio transports) + /// + /// Verification result with detailed status + Task VerifyAsync(); + } - + /// /// Result of a bridge verification attempt /// @@ -47,17 +63,17 @@ namespace MCPForUnity.Editor.Services /// Whether the verification was successful /// public bool Success { get; set; } - + /// /// Human-readable message about the verification result /// public string Message { get; set; } - + /// /// Whether the handshake was valid (FRAMING=1 protocol) /// public bool HandshakeValid { get; set; } - + /// /// Whether the ping/pong exchange succeeded /// diff --git a/MCPForUnity/Editor/Services/IClientConfigurationService.cs b/MCPForUnity/Editor/Services/IClientConfigurationService.cs index e647424..24b01fa 100644 --- a/MCPForUnity/Editor/Services/IClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/IClientConfigurationService.cs @@ -12,13 +12,13 @@ namespace MCPForUnity.Editor.Services /// /// The client to configure void ConfigureClient(McpClient client); - + /// /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) /// /// Summary of configuration results ClientConfigurationSummary ConfigureAllDetectedClients(); - + /// /// Checks the configuration status of a client /// @@ -26,31 +26,31 @@ namespace MCPForUnity.Editor.Services /// If true, attempts to auto-fix mismatched paths /// True if status changed, false otherwise bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true); - + /// /// Registers MCP for Unity with Claude Code CLI /// void RegisterClaudeCode(); - + /// /// Unregisters MCP for Unity from Claude Code CLI /// void UnregisterClaudeCode(); - + /// /// Gets the configuration file path for a client /// /// The client /// Platform-specific config path string GetConfigPath(McpClient client); - + /// /// Generates the configuration JSON for a client /// /// The client /// JSON configuration string string GenerateConfigJson(McpClient client); - + /// /// Gets human-readable installation steps for a client /// @@ -58,7 +58,7 @@ namespace MCPForUnity.Editor.Services /// Installation instructions string GetInstallationSteps(McpClient client); } - + /// /// Summary of configuration results for multiple clients /// @@ -68,22 +68,22 @@ namespace MCPForUnity.Editor.Services /// Number of clients successfully configured /// public int SuccessCount { get; set; } - + /// /// Number of clients that failed to configure /// public int FailureCount { get; set; } - + /// /// Number of clients skipped (already configured or tool not found) /// public int SkippedCount { get; set; } - + /// /// Detailed messages for each client /// public System.Collections.Generic.List Messages { get; set; } = new(); - + /// /// Gets a human-readable summary message /// diff --git a/MCPForUnity/Editor/Services/IPackageUpdateService.cs b/MCPForUnity/Editor/Services/IPackageUpdateService.cs index a9a1491..9d4d2e4 100644 --- a/MCPForUnity/Editor/Services/IPackageUpdateService.cs +++ b/MCPForUnity/Editor/Services/IPackageUpdateService.cs @@ -11,7 +11,7 @@ namespace MCPForUnity.Editor.Services /// The current package version /// Update check result containing availability and latest version info UpdateCheckResult CheckForUpdate(string currentVersion); - + /// /// Compares two version strings to determine if the first is newer than the second /// @@ -19,19 +19,19 @@ namespace MCPForUnity.Editor.Services /// Second version string /// True if version1 is newer than version2 bool IsNewerVersion(string version1, string version2); - + /// /// Determines if the package was installed via Git or Asset Store /// /// True if installed via Git, false if Asset Store or unknown bool IsGitInstallation(); - + /// /// Clears the cached update check data, forcing a fresh check on next request /// void ClearCache(); } - + /// /// Result of an update check operation /// @@ -41,17 +41,17 @@ namespace MCPForUnity.Editor.Services /// Whether an update is available /// public bool UpdateAvailable { get; set; } - + /// /// The latest version available (null if check failed or no update) /// public string LatestVersion { get; set; } - + /// /// Whether the check was successful (false if network error, etc.) /// public bool CheckSucceeded { get; set; } - + /// /// Optional message about the check result /// diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs b/MCPForUnity/Editor/Services/IPathResolverService.cs index 9968af6..104c311 100644 --- a/MCPForUnity/Editor/Services/IPathResolverService.cs +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -6,84 +6,56 @@ namespace MCPForUnity.Editor.Services public interface IPathResolverService { /// - /// Gets the MCP server path (respects override if set) + /// Gets the uvx package manager path (respects override if set) /// - /// Path to the MCP server directory containing server.py, or null if not found - string GetMcpServerPath(); - - /// - /// Gets the UV package manager path (respects override if set) - /// - /// Path to the uv executable, or null if not found - string GetUvPath(); - + /// Path to the uvx executable, or null if not found + string GetUvxPath(); + /// /// Gets the Claude CLI path (respects override if set) /// /// Path to the claude executable, or null if not found string GetClaudeCliPath(); - + /// /// Checks if Python is detected on the system /// /// True if Python is found bool IsPythonDetected(); - - /// - /// Checks if UV is detected on the system - /// - /// True if UV is found - bool IsUvDetected(); - + /// /// Checks if Claude CLI is detected on the system /// /// True if Claude CLI is found bool IsClaudeCliDetected(); - + /// - /// Sets an override for the MCP server path + /// Sets an override for the uvx path /// /// Path to override with - void SetMcpServerOverride(string path); - - /// - /// Sets an override for the UV path - /// - /// Path to override with - void SetUvPathOverride(string path); - + void SetUvxPathOverride(string path); + /// /// Sets an override for the Claude CLI path /// /// Path to override with void SetClaudeCliPathOverride(string path); - + /// - /// Clears the MCP server path override + /// Clears the uvx path override /// - void ClearMcpServerOverride(); - - /// - /// Clears the UV path override - /// - void ClearUvPathOverride(); - + void ClearUvxPathOverride(); + /// /// Clears the Claude CLI path override /// void ClearClaudeCliPathOverride(); - + /// - /// Gets whether a MCP server path override is active + /// Gets whether a uvx path override is active /// - bool HasMcpServerOverride { get; } - - /// - /// Gets whether a UV path override is active - /// - bool HasUvPathOverride { get; } - + bool HasUvxPathOverride { get; } + /// /// Gets whether a Claude CLI path override is active /// diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs deleted file mode 100644 index dde40d1..0000000 --- a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; -using MCPForUnity.Editor.Data; - -namespace MCPForUnity.Editor.Services -{ - public interface IPythonToolRegistryService - { - IEnumerable GetAllRegistries(); - bool NeedsSync(PythonToolsAsset registry, TextAsset file); - void RecordSync(PythonToolsAsset registry, TextAsset file); - string ComputeHash(TextAsset file); - } -} diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta deleted file mode 100644 index 3f4835f..0000000 --- a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a2487319df5cc47baa2c635b911038c5 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs new file mode 100644 index 0000000..54c7b9c --- /dev/null +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -0,0 +1,46 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Interface for server management operations + /// + public interface IServerManagementService + { + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + bool ClearUvxCache(); + + /// + /// Start the local HTTP server in a new terminal window. + /// Stops any existing server on the port and clears the uvx cache first. + /// + /// True if server was started successfully, false otherwise + bool StartLocalHttpServer(); + + /// + /// Stop the local HTTP server by finding the process listening on the configured port + /// + bool StopLocalHttpServer(); + + /// + /// Attempts to get the command that will be executed when starting the local HTTP server + /// + /// The command that will be executed when available + /// Reason why a command could not be produced + /// True if a command is available, false otherwise + bool TryGetLocalHttpServerCommand(out string command, out string error); + + /// + /// Check if the configured HTTP URL is a local address + /// + /// True if URL is local (localhost, 127.0.0.1, etc.) + bool IsLocalUrl(); + + /// + /// Check if the local HTTP server can be started + /// + /// True if HTTP transport is enabled and URL is local + bool CanStartLocalServer(); + } +} diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs.meta b/MCPForUnity/Editor/Services/IServerManagementService.cs.meta new file mode 100644 index 0000000..9f12dc3 --- /dev/null +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d41bfc9780b774affa6afbffd081eb79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IToolDiscoveryService.cs b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs new file mode 100644 index 0000000..01ceb2b --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Metadata for a discovered tool + /// + public class ToolMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public bool StructuredOutput { get; set; } + public List Parameters { get; set; } + public string ClassName { get; set; } + public string Namespace { get; set; } + public bool AutoRegister { get; set; } = true; + public bool RequiresPolling { get; set; } = false; + public string PollAction { get; set; } = "status"; + } + + /// + /// Metadata for a tool parameter + /// + public class ParameterMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public string Type { get; set; } // "string", "int", "bool", "float", etc. + public bool Required { get; set; } + public string DefaultValue { get; set; } + } + + /// + /// Service for discovering MCP tools via reflection + /// + public interface IToolDiscoveryService + { + /// + /// Discovers all tools marked with [McpForUnityTool] + /// + List DiscoverAllTools(); + + /// + /// Gets metadata for a specific tool + /// + ToolMetadata GetToolMetadata(string toolName); + + /// + /// Invalidates the tool discovery cache + /// + void InvalidateCache(); + } +} diff --git a/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta new file mode 100644 index 0000000..a25b749 --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 497592a93fd994b2cb9803e7c8636ff7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IToolSyncService.cs b/MCPForUnity/Editor/Services/IToolSyncService.cs deleted file mode 100644 index 3a62fdf..0000000 --- a/MCPForUnity/Editor/Services/IToolSyncService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; - -namespace MCPForUnity.Editor.Services -{ - public class ToolSyncResult - { - public int CopiedCount { get; set; } - public int SkippedCount { get; set; } - public int ErrorCount { get; set; } - public List Messages { get; set; } = new List(); - public bool Success => ErrorCount == 0; - } - - public interface IToolSyncService - { - ToolSyncResult SyncProjectTools(string destToolsDir); - } -} diff --git a/MCPForUnity/Editor/Services/IToolSyncService.cs.meta b/MCPForUnity/Editor/Services/IToolSyncService.cs.meta deleted file mode 100644 index 0282828..0000000 --- a/MCPForUnity/Editor/Services/IToolSyncService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b9627dbaa92d24783a9f20e42efcea18 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/MCPServiceLocator.cs b/MCPForUnity/Editor/Services/MCPServiceLocator.cs index a743d4c..d537182 100644 --- a/MCPForUnity/Editor/Services/MCPServiceLocator.cs +++ b/MCPForUnity/Editor/Services/MCPServiceLocator.cs @@ -1,4 +1,7 @@ using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnity.Editor.Services { @@ -10,20 +13,22 @@ namespace MCPForUnity.Editor.Services private static IBridgeControlService _bridgeService; private static IClientConfigurationService _clientService; private static IPathResolverService _pathService; - private static IPythonToolRegistryService _pythonToolRegistryService; private static ITestRunnerService _testRunnerService; - private static IToolSyncService _toolSyncService; private static IPackageUpdateService _packageUpdateService; private static IPlatformService _platformService; + private static IToolDiscoveryService _toolDiscoveryService; + private static IServerManagementService _serverManagementService; + private static TransportManager _transportManager; public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService(); public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); public static IPathResolverService Paths => _pathService ??= new PathResolverService(); - public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService(); public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); - public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService(); public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); public static IPlatformService Platform => _platformService ??= new PlatformService(); + public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService(); + public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService(); + public static TransportManager TransportManager => _transportManager ??= new TransportManager(); /// /// Registers a custom implementation for a service (useful for testing) @@ -38,16 +43,18 @@ namespace MCPForUnity.Editor.Services _clientService = c; else if (implementation is IPathResolverService p) _pathService = p; - else if (implementation is IPythonToolRegistryService ptr) - _pythonToolRegistryService = ptr; else if (implementation is ITestRunnerService t) _testRunnerService = t; - else if (implementation is IToolSyncService ts) - _toolSyncService = ts; else if (implementation is IPackageUpdateService pu) _packageUpdateService = pu; else if (implementation is IPlatformService ps) _platformService = ps; + else if (implementation is IToolDiscoveryService td) + _toolDiscoveryService = td; + else if (implementation is IServerManagementService sm) + _serverManagementService = sm; + else if (implementation is TransportManager tm) + _transportManager = tm; } /// @@ -58,20 +65,22 @@ namespace MCPForUnity.Editor.Services (_bridgeService as IDisposable)?.Dispose(); (_clientService as IDisposable)?.Dispose(); (_pathService as IDisposable)?.Dispose(); - (_pythonToolRegistryService as IDisposable)?.Dispose(); (_testRunnerService as IDisposable)?.Dispose(); - (_toolSyncService as IDisposable)?.Dispose(); (_packageUpdateService as IDisposable)?.Dispose(); (_platformService as IDisposable)?.Dispose(); + (_toolDiscoveryService as IDisposable)?.Dispose(); + (_serverManagementService as IDisposable)?.Dispose(); + (_transportManager as IDisposable)?.Dispose(); _bridgeService = null; _clientService = null; _pathService = null; - _pythonToolRegistryService = null; _testRunnerService = null; - _toolSyncService = null; _packageUpdateService = null; _platformService = null; + _toolDiscoveryService = null; + _serverManagementService = null; + _transportManager = null; } } } diff --git a/MCPForUnity/Editor/Services/PackageUpdateService.cs b/MCPForUnity/Editor/Services/PackageUpdateService.cs index 7a5bc9f..b4384d9 100644 --- a/MCPForUnity/Editor/Services/PackageUpdateService.cs +++ b/MCPForUnity/Editor/Services/PackageUpdateService.cs @@ -3,6 +3,7 @@ using System.Net; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Services { @@ -11,8 +12,8 @@ namespace MCPForUnity.Editor.Services /// public class PackageUpdateService : IPackageUpdateService { - private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck"; - private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion"; + private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck; + private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion; private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; /// diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 083115f..4b6b07f 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -1,6 +1,9 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; @@ -12,164 +15,107 @@ namespace MCPForUnity.Editor.Services /// public class PathResolverService : IPathResolverService { - private const string PythonDirOverrideKey = "MCPForUnity.PythonDirOverride"; - private const string UvPathOverrideKey = "MCPForUnity.UvPath"; - private const string ClaudeCliPathOverrideKey = "MCPForUnity.ClaudeCliPath"; + public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null)); + public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null)); - public bool HasMcpServerOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(PythonDirOverrideKey, null)); - public bool HasUvPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvPathOverrideKey, null)); - public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(ClaudeCliPathOverrideKey, null)); - - public string GetMcpServerPath() + public string GetUvxPath() { - // Check for override first - string overridePath = EditorPrefs.GetString(PythonDirOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(Path.Combine(overridePath, "server.py"))) - { - return overridePath; - } - - // Fall back to automatic detection - return McpPathResolver.FindPackagePythonDirectory(false); - } - - public string GetUvPath() - { - // Check for override first - string overridePath = EditorPrefs.GetString(UvPathOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - return overridePath; - } - - // Fall back to automatic detection try { - return ServerInstaller.FindUvPath(); + string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + if (!string.IsNullOrEmpty(overridePath)) + { + return overridePath; + } } catch { - return null; + // ignore EditorPrefs read errors and fall back to default command + McpLog.Debug("No uvx path override found, falling back to default command"); } + + return "uvx"; } public string GetClaudeCliPath() { - // Check for override first - string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + try { - return overridePath; + string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + return overridePath; + } + } + catch { /* ignore */ } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string[] candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude", "claude.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"), + "claude.exe" + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string[] candidates = new[] + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude") + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string[] candidates = new[] + { + "/usr/bin/claude", + "/usr/local/bin/claude", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude") + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } } - // Fall back to automatic detection - return ExecPath.ResolveClaude(); + return null; } public bool IsPythonDetected() { try { - // Windows-specific Python detection - if (Application.platform == RuntimePlatform.WindowsEditor) + var psi = new ProcessStartInfo { - // Common Windows Python installation paths - string[] windowsCandidates = - { - @"C:\Python314\python.exe", - @"C:\Python313\python.exe", - @"C:\Python312\python.exe", - @"C:\Python311\python.exe", - @"C:\Python310\python.exe", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python314\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python314\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), - }; - - foreach (string c in windowsCandidates) - { - if (File.Exists(c)) return true; - } - - // Try 'where python' command (Windows equivalent of 'which') - var psi = new ProcessStartInfo - { - FileName = "where", - Arguments = "python", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using (var p = Process.Start(psi)) - { - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) - { - string[] lines = outp.Split('\n'); - foreach (string line in lines) - { - string trimmed = line.Trim(); - if (File.Exists(trimmed)) return true; - } - } - } - } - else - { - // macOS/Linux detection - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", - "/usr/bin/python3", - "/opt/local/bin/python3", - Path.Combine(home, ".local", "bin", "python3"), - "/Library/Frameworks/Python.framework/Versions/3.14/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3", - }; - foreach (string c in candidates) - { - if (File.Exists(c)) return true; - } - - // Try 'which python3' - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = "python3", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using (var p = Process.Start(psi)) - { - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; - } - } + FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + p.WaitForExit(2000); + return p.ExitCode == 0; + } + catch + { + return false; } - catch { } - return false; - } - - public bool IsUvDetected() - { - return !string.IsNullOrEmpty(GetUvPath()); } public bool IsClaudeCliDetected() @@ -177,36 +123,20 @@ namespace MCPForUnity.Editor.Services return !string.IsNullOrEmpty(GetClaudeCliPath()); } - public void SetMcpServerOverride(string path) + public void SetUvxPathOverride(string path) { if (string.IsNullOrEmpty(path)) { - ClearMcpServerOverride(); - return; - } - - if (!File.Exists(Path.Combine(path, "server.py"))) - { - throw new ArgumentException("The selected folder does not contain server.py"); - } - - EditorPrefs.SetString(PythonDirOverrideKey, path); - } - - public void SetUvPathOverride(string path) - { - if (string.IsNullOrEmpty(path)) - { - ClearUvPathOverride(); + ClearUvxPathOverride(); return; } if (!File.Exists(path)) { - throw new ArgumentException("The selected UV executable does not exist"); + throw new ArgumentException("The selected uvx executable does not exist"); } - EditorPrefs.SetString(UvPathOverrideKey, path); + EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path); } public void SetClaudeCliPathOverride(string path) @@ -222,24 +152,17 @@ namespace MCPForUnity.Editor.Services throw new ArgumentException("The selected Claude CLI executable does not exist"); } - EditorPrefs.SetString(ClaudeCliPathOverrideKey, path); - // Also update the ExecPath helper for backwards compatibility - ExecPath.SetClaudeCliPath(path); + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path); } - public void ClearMcpServerOverride() + public void ClearUvxPathOverride() { - EditorPrefs.DeleteKey(PythonDirOverrideKey); - } - - public void ClearUvPathOverride() - { - EditorPrefs.DeleteKey(UvPathOverrideKey); + EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride); } public void ClearClaudeCliPathOverride() { - EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); + EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride); } } } diff --git a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs deleted file mode 100644 index 1fab20c..0000000 --- a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Data; - -namespace MCPForUnity.Editor.Services -{ - public class PythonToolRegistryService : IPythonToolRegistryService - { - public IEnumerable GetAllRegistries() - { - // Find all PythonToolsAsset instances in the project - string[] guids = AssetDatabase.FindAssets("t:PythonToolsAsset"); - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - var asset = AssetDatabase.LoadAssetAtPath(path); - if (asset != null) - yield return asset; - } - } - - public bool NeedsSync(PythonToolsAsset registry, TextAsset file) - { - if (!registry.useContentHashing) return true; - - string currentHash = ComputeHash(file); - return registry.NeedsSync(file, currentHash); - } - - public void RecordSync(PythonToolsAsset registry, TextAsset file) - { - string hash = ComputeHash(file); - registry.RecordSync(file, hash); - EditorUtility.SetDirty(registry); - } - - public string ComputeHash(TextAsset file) - { - if (file == null || string.IsNullOrEmpty(file.text)) - return string.Empty; - - using (var sha256 = SHA256.Create()) - { - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(file.text); - byte[] hash = sha256.ComputeHash(bytes); - return BitConverter.ToString(hash).Replace("-", "").ToLower(); - } - } - } -} diff --git a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta deleted file mode 100644 index 9fba1e9..0000000 --- a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2da2869749c764f16a45e010eefbd679 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs new file mode 100644 index 0000000..8a323ab --- /dev/null +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -0,0 +1,496 @@ +using System; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for managing MCP server lifecycle + /// + public class ServerManagementService : IServerManagementService + { + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + public bool ClearUvxCache() + { + try + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string uvCommand = uvxPath.Remove(uvxPath.Length - 1, 1); + + // Get the package name + string packageName = "mcp-for-unity"; + + // Run uvx cache clean command + string args = $"cache clean {packageName}"; + + bool success; + string stdout; + string stderr; + + success = ExecuteUvCommand(uvCommand, args, out stdout, out stderr); + + if (success) + { + McpLog.Debug($"uv cache cleared successfully: {stdout}"); + return true; + } + else + { + string errorMessage = string.IsNullOrEmpty(stderr) + ? "Unknown error" + : stderr; + + McpLog.Error($"Failed to clear uv cache using '{uvCommand} {args}': {errorMessage}. Ensure uv is installed, available on PATH, or set an override in Advanced Settings."); + return false; + } + } + catch (Exception ex) + { + McpLog.Error($"Error clearing uv cache: {ex.Message}"); + return false; + } + } + + private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, out string stderr) + { + stdout = null; + stderr = null; + + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string uvPath = uvxPath.Remove(uvxPath.Length - 1, 1); + + if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase)) + { + return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000); + } + + string command = $"{uvPath} {args}"; + string extraPathPrepend = GetPlatformSpecificPathPrepend(); + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + + string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh"; + + if (!string.IsNullOrEmpty(shell) && File.Exists(shell)) + { + string escaped = command.Replace("\"", "\\\""); + return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + + return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + + private string GetPlatformSpecificPathPrepend() + { + if (Application.platform == RuntimePlatform.OSXEditor) + { + return string.Join(Path.PathSeparator.ToString(), new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + + if (Application.platform == RuntimePlatform.LinuxEditor) + { + return string.Join(Path.PathSeparator.ToString(), new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return string.Join(Path.PathSeparator.ToString(), new[] + { + !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null, + !string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null + }.Where(p => !string.IsNullOrEmpty(p)).ToArray()); + } + + return null; + } + + /// + /// Start the local HTTP server in a new terminal window. + /// Stops any existing server on the port and clears the uvx cache first. + /// + public bool StartLocalHttpServer() + { + if (!TryGetLocalHttpServerCommand(out var command, out var error)) + { + EditorUtility.DisplayDialog( + "Cannot Start HTTP Server", + error ?? "The server command could not be constructed with the current settings.", + "OK"); + return false; + } + + // First, try to stop any existing server + StopLocalHttpServer(); + + // Clear the cache to ensure we get a fresh version + try + { + ClearUvxCache(); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to clear cache before starting server: {ex.Message}"); + } + + if (EditorUtility.DisplayDialog( + "Start Local HTTP Server", + $"This will start the MCP server in HTTP mode:\n\n{command}\n\n" + + "The server will run in a separate terminal window. " + + "Close the terminal to stop the server.\n\n" + + "Continue?", + "Start Server", + "Cancel")) + { + try + { + // Start the server in a new terminal window (cross-platform) + var startInfo = CreateTerminalProcessStartInfo(command); + + System.Diagnostics.Process.Start(startInfo); + + McpLog.Info($"Started local HTTP server: {command}"); + return true; + } + catch (Exception ex) + { + McpLog.Error($"Failed to start server: {ex.Message}"); + EditorUtility.DisplayDialog( + "Error", + $"Failed to start server: {ex.Message}", + "OK"); + return false; + } + } + + return false; + } + + /// + /// Stop the local HTTP server by finding the process listening on the configured port + /// + public bool StopLocalHttpServer() + { + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + McpLog.Warn("Cannot stop server: URL is not local."); + return false; + } + + try + { + var uri = new Uri(httpUrl); + int port = uri.Port; + + if (port <= 0) + { + McpLog.Warn("Cannot stop server: Invalid port."); + return false; + } + + McpLog.Info($"Attempting to stop any process listening on local port {port}. This will terminate the owning process even if it is not the MCP server."); + + int pid = GetProcessIdForPort(port); + if (pid > 0) + { + KillProcess(pid); + McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})"); + return true; + } + else + { + McpLog.Info($"No process found listening on port {port}"); + return false; + } + } + catch (Exception ex) + { + McpLog.Error($"Failed to stop server: {ex.Message}"); + return false; + } + } + + private int GetProcessIdForPort(int port) + { + try + { + string stdout, stderr; + bool success; + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // netstat -ano | findstr : + success = ExecPath.TryRun("cmd.exe", $"/c netstat -ano | findstr :{port}", Application.dataPath, out stdout, out stderr); + if (success && !string.IsNullOrEmpty(stdout)) + { + var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains("LISTENING")) + { + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0 && int.TryParse(parts[parts.Length - 1], out int pid)) + { + return pid; + } + } + } + } + } + else + { + // lsof -i : -t + // Use /usr/sbin/lsof directly as it might not be in PATH for Unity + string lsofPath = "/usr/sbin/lsof"; + if (!System.IO.File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback + + success = ExecPath.TryRun(lsofPath, $"-i :{port} -t", Application.dataPath, out stdout, out stderr); + if (success && !string.IsNullOrWhiteSpace(stdout)) + { + var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var pidString in pidStrings) + { + if (int.TryParse(pidString.Trim(), out int pid)) + { + if (pidStrings.Length > 1) + { + McpLog.Debug($"Multiple processes found on port {port}; attempting to stop PID {pid} returned by lsof -t."); + } + + return pid; + } + } + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Error checking port {port}: {ex.Message}"); + } + return -1; + } + + private void KillProcess(int pid) + { + try + { + string stdout, stderr; + if (Application.platform == RuntimePlatform.WindowsEditor) + { + ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr); + } + else + { + ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr); + } + } + catch (Exception ex) + { + McpLog.Error($"Error killing process {pid}: {ex.Message}"); + } + } + + /// + /// Attempts to build the command used for starting the local HTTP server + /// + public bool TryGetLocalHttpServerCommand(out string command, out string error) + { + command = null; + error = null; + + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + if (!useHttpTransport) + { + error = "HTTP transport is disabled. Enable it in the MCP For Unity window first."; + return false; + } + + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (!IsLocalUrl()) + { + error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; + return false; + } + + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrEmpty(uvxPath)) + { + error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings."; + return false; + } + + string args = string.IsNullOrEmpty(fromUrl) + ? $"{packageName} --transport http --http-url {httpUrl}" + : $"--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; + + command = $"{uvxPath} {args}"; + return true; + } + + /// + /// Check if the configured HTTP URL is a local address + /// + public bool IsLocalUrl() + { + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + return IsLocalUrl(httpUrl); + } + + /// + /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0) + /// + private static bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url)) return false; + + try + { + var uri = new Uri(url); + string host = uri.Host.ToLower(); + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; + } + catch + { + return false; + } + } + + /// + /// Check if the local HTTP server can be started + /// + public bool CanStartLocalServer() + { + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + return useHttpTransport && IsLocalUrl(); + } + + /// + /// Creates a ProcessStartInfo for opening a terminal window with the given command + /// Works cross-platform: macOS, Windows, and Linux + /// + private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) + { + if (string.IsNullOrWhiteSpace(command)) + throw new ArgumentException("Command cannot be empty", nameof(command)); + + command = command.Replace("\r", "").Replace("\n", ""); + +#if UNITY_EDITOR_OSX + // macOS: Use osascript directly to avoid shell metacharacter injection via bash + // Escape for AppleScript: backslash and double quotes + string escapedCommand = command.Replace("\\", "\\\\").Replace("\"", "\\\""); + return new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/osascript", + Arguments = $"-e \"tell application \\\"Terminal\\\" to do script \\\"{escapedCommand}\\\" activate\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#elif UNITY_EDITOR_WIN + // Windows: Use cmd.exe with start command to open new window + // Wrap in quotes for /k and escape internal quotes + string escapedCommandWin = command.Replace("\"", "\\\""); + return new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{escapedCommandWin}\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#else + // Linux: Try common terminal emulators + // We use bash -c to execute the command, so we must properly quote/escape for bash + // Escape single quotes for the inner bash string + string escapedCommandLinux = command.Replace("'", "'\\''"); + // Wrap the command in single quotes for bash -c + string script = $"'{escapedCommandLinux}; exec bash'"; + // Escape double quotes for the outer Process argument string + string escapedScriptForArg = script.Replace("\"", "\\\""); + string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\""; + + string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" }; + string terminalCmd = null; + + foreach (var term in terminals) + { + try + { + var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "which", + Arguments = term, + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous + if (which.ExitCode == 0) + { + terminalCmd = term; + break; + } + } + catch { } + } + + if (terminalCmd == null) + { + terminalCmd = "xterm"; // Fallback + } + + // Different terminals have different argument formats + string args; + if (terminalCmd == "gnome-terminal") + { + args = $"-- {bashCmdArgs}"; + } + else if (terminalCmd == "konsole") + { + args = $"-e {bashCmdArgs}"; + } + else if (terminalCmd == "xfce4-terminal") + { + // xfce4-terminal expects -e "command string" or -e command arg + args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\""; + } + else // xterm and others + { + args = $"-hold -e {bashCmdArgs}"; + } + + return new System.Diagnostics.ProcessStartInfo + { + FileName = terminalCmd, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true + }; +#endif + } + } +} diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs.meta b/MCPForUnity/Editor/Services/ServerManagementService.cs.meta new file mode 100644 index 0000000..8b0fea0 --- /dev/null +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e60df35c5a76462d8aaa8078da86d75 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs new file mode 100644 index 0000000..0f3406a --- /dev/null +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + public class ToolDiscoveryService : IToolDiscoveryService + { + private Dictionary _cachedTools; + + public List DiscoverAllTools() + { + if (_cachedTools != null) + { + return _cachedTools.Values.ToList(); + } + + _cachedTools = new Dictionary(); + + // Scan all assemblies for [McpForUnityTool] attributes + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var assembly in assemblies) + { + try + { + var types = assembly.GetTypes(); + + foreach (var type in types) + { + var toolAttr = type.GetCustomAttribute(); + if (toolAttr == null) + continue; + + var metadata = ExtractToolMetadata(type, toolAttr); + if (metadata != null) + { + _cachedTools[metadata.Name] = metadata; + } + } + } + catch (Exception ex) + { + // Skip assemblies that can't be reflected + McpLog.Info($"Skipping assembly {assembly.FullName}: {ex.Message}"); + } + } + + McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection"); + return _cachedTools.Values.ToList(); + } + + public ToolMetadata GetToolMetadata(string toolName) + { + if (_cachedTools == null) + { + DiscoverAllTools(); + } + + return _cachedTools.TryGetValue(toolName, out var metadata) ? metadata : null; + } + + private ToolMetadata ExtractToolMetadata(Type type, McpForUnityToolAttribute toolAttr) + { + try + { + // Get tool name + string toolName = toolAttr.Name; + if (string.IsNullOrEmpty(toolName)) + { + // Derive from class name: CaptureScreenshotTool -> capture_screenshot + toolName = ConvertToSnakeCase(type.Name.Replace("Tool", "")); + } + + // Get description + string description = toolAttr.Description ?? $"Tool: {toolName}"; + + // Extract parameters + var parameters = ExtractParameters(type); + + return new ToolMetadata + { + Name = toolName, + Description = description, + StructuredOutput = toolAttr.StructuredOutput, + Parameters = parameters, + ClassName = type.Name, + Namespace = type.Namespace ?? "", + AutoRegister = toolAttr.AutoRegister, + RequiresPolling = toolAttr.RequiresPolling, + PollAction = string.IsNullOrEmpty(toolAttr.PollAction) ? "status" : toolAttr.PollAction + }; + } + catch (Exception ex) + { + McpLog.Error($"Failed to extract metadata for {type.Name}: {ex.Message}"); + return null; + } + } + + private List ExtractParameters(Type type) + { + var parameters = new List(); + + // Look for nested Parameters class + var parametersType = type.GetNestedType("Parameters"); + if (parametersType == null) + { + return parameters; + } + + // Get all properties with [ToolParameter] + var properties = parametersType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + var paramAttr = prop.GetCustomAttribute(); + if (paramAttr == null) + continue; + + string paramName = prop.Name; + string paramType = GetParameterType(prop.PropertyType); + + parameters.Add(new ParameterMetadata + { + Name = paramName, + Description = paramAttr.Description, + Type = paramType, + Required = paramAttr.Required, + DefaultValue = paramAttr.DefaultValue + }); + } + + return parameters; + } + + private string GetParameterType(Type type) + { + // Handle nullable types + if (Nullable.GetUnderlyingType(type) != null) + { + type = Nullable.GetUnderlyingType(type); + } + + // Map C# types to JSON schema types + if (type == typeof(string)) + return "string"; + if (type == typeof(int) || type == typeof(long)) + return "integer"; + if (type == typeof(float) || type == typeof(double)) + return "number"; + if (type == typeof(bool)) + return "boolean"; + if (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type)) + return "array"; + + return "object"; + } + + private string ConvertToSnakeCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Convert PascalCase to snake_case + var result = System.Text.RegularExpressions.Regex.Replace( + input, + "([a-z0-9])([A-Z])", + "$1_$2" + ).ToLower(); + + return result; + } + + public void InvalidateCache() + { + _cachedTools = null; + } + } +} diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta new file mode 100644 index 0000000..46b7403 --- /dev/null +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec81a561be4c14c9cb243855d3273a94 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs b/MCPForUnity/Editor/Services/ToolSyncService.cs deleted file mode 100644 index bd17f99..0000000 --- a/MCPForUnity/Editor/Services/ToolSyncService.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MCPForUnity.Editor.Helpers; -using UnityEditor; - -namespace MCPForUnity.Editor.Services -{ - public class ToolSyncService : IToolSyncService - { - private readonly IPythonToolRegistryService _registryService; - - public ToolSyncService(IPythonToolRegistryService registryService = null) - { - _registryService = registryService ?? MCPServiceLocator.PythonToolRegistry; - } - - public ToolSyncResult SyncProjectTools(string destToolsDir) - { - var result = new ToolSyncResult(); - - try - { - Directory.CreateDirectory(destToolsDir); - - // Get all PythonToolsAsset instances in the project - var registries = _registryService.GetAllRegistries().ToList(); - - if (!registries.Any()) - { - McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools"); - return result; - } - - var syncedFiles = new HashSet(); - - // Batch all asset modifications together to minimize reimports - AssetDatabase.StartAssetEditing(); - try - { - foreach (var registry in registries) - { - foreach (var file in registry.GetValidFiles()) - { - try - { - // Check if needs syncing (hash-based or always) - if (_registryService.NeedsSync(registry, file)) - { - string destPath = Path.Combine(destToolsDir, file.name + ".py"); - - // Write the Python file content - File.WriteAllText(destPath, file.text); - - // Record sync - _registryService.RecordSync(registry, file); - - result.CopiedCount++; - syncedFiles.Add(destPath); - McpLog.Info($"Synced Python tool: {file.name}.py"); - } - else - { - string destPath = Path.Combine(destToolsDir, file.name + ".py"); - syncedFiles.Add(destPath); - result.SkippedCount++; - } - } - catch (Exception ex) - { - result.ErrorCount++; - result.Messages.Add($"Failed to sync {file.name}: {ex.Message}"); - } - } - - // Cleanup stale states in registry - registry.CleanupStaleStates(); - EditorUtility.SetDirty(registry); - } - - // Cleanup stale Python files in destination - CleanupStaleFiles(destToolsDir, syncedFiles); - } - finally - { - // End batch editing - this triggers a single asset refresh - AssetDatabase.StopAssetEditing(); - } - - // Save all modified registries - AssetDatabase.SaveAssets(); - } - catch (Exception ex) - { - result.ErrorCount++; - result.Messages.Add($"Sync failed: {ex.Message}"); - } - - return result; - } - - private void CleanupStaleFiles(string destToolsDir, HashSet currentFiles) - { - try - { - if (!Directory.Exists(destToolsDir)) return; - - // Find all .py files in destination that aren't in our current set - var existingFiles = Directory.GetFiles(destToolsDir, "*.py"); - - foreach (var file in existingFiles) - { - if (!currentFiles.Contains(file)) - { - try - { - File.Delete(file); - McpLog.Info($"Cleaned up stale tool: {Path.GetFileName(file)}"); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to cleanup {file}: {ex.Message}"); - } - } - } - } - catch (Exception ex) - { - McpLog.Warn($"Failed to cleanup stale files: {ex.Message}"); - } - } - } -} diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs.meta b/MCPForUnity/Editor/Services/ToolSyncService.cs.meta deleted file mode 100644 index 31db439..0000000 --- a/MCPForUnity/Editor/Services/ToolSyncService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9ad084cf3b6c04174b9202bf63137bae -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta b/MCPForUnity/Editor/Services/Transport.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta rename to MCPForUnity/Editor/Services/Transport.meta index fd2be78..58fe0d7 100644 --- a/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta +++ b/MCPForUnity/Editor/Services/Transport.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d5876265244e44b0dbea3a1351bf24be +guid: 8d189635a5d364f55a810203798c09ba folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs new file mode 100644 index 0000000..3d8584f --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Abstraction for MCP transport implementations (e.g. WebSocket push, stdio). + /// + public interface IMcpTransportClient + { + bool IsConnected { get; } + string TransportName { get; } + TransportState State { get; } + + Task StartAsync(); + Task StopAsync(); + Task VerifyAsync(); + } +} diff --git a/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta new file mode 100644 index 0000000..2bdf095 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 042446a50a4744170bb294acf827376f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs new file mode 100644 index 0000000..5490508 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Centralised command execution pipeline shared by all transport implementations. + /// Guarantees that MCP commands are executed on the Unity main thread while preserving + /// the legacy response format expected by the server. + /// + internal static class TransportCommandDispatcher + { + private sealed class PendingCommand + { + public PendingCommand( + string commandJson, + TaskCompletionSource completionSource, + CancellationToken cancellationToken, + CancellationTokenRegistration registration) + { + CommandJson = commandJson; + CompletionSource = completionSource; + CancellationToken = cancellationToken; + CancellationRegistration = registration; + } + + public string CommandJson { get; } + public TaskCompletionSource CompletionSource { get; } + public CancellationToken CancellationToken { get; } + public CancellationTokenRegistration CancellationRegistration { get; } + public bool IsExecuting { get; set; } + + public void Dispose() + { + CancellationRegistration.Dispose(); + } + + public void TrySetResult(string payload) + { + CompletionSource.TrySetResult(payload); + } + + public void TrySetCanceled() + { + CompletionSource.TrySetCanceled(CancellationToken); + } + } + + private static readonly Dictionary Pending = new(); + private static readonly object PendingLock = new(); + private static bool updateHooked; + private static bool initialised; + + /// + /// Schedule a command for execution on the Unity main thread and await its JSON response. + /// + public static Task ExecuteCommandJsonAsync(string commandJson, CancellationToken cancellationToken) + { + if (commandJson is null) + { + throw new ArgumentNullException(nameof(commandJson)); + } + + EnsureInitialised(); + + var id = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var registration = cancellationToken.CanBeCanceled + ? cancellationToken.Register(() => CancelPending(id, cancellationToken)) + : default; + + var pending = new PendingCommand(commandJson, tcs, cancellationToken, registration); + + lock (PendingLock) + { + Pending[id] = pending; + HookUpdate(); + } + + return tcs.Task; + } + + private static void EnsureInitialised() + { + if (initialised) + { + return; + } + + CommandRegistry.Initialize(); + initialised = true; + } + + private static void HookUpdate() + { + if (updateHooked) + { + return; + } + + updateHooked = true; + EditorApplication.update += ProcessQueue; + } + + private static void UnhookUpdateIfIdle() + { + if (Pending.Count > 0 || !updateHooked) + { + return; + } + + updateHooked = false; + EditorApplication.update -= ProcessQueue; + } + + private static void ProcessQueue() + { + List<(string id, PendingCommand pending)> ready; + + lock (PendingLock) + { + ready = new List<(string, PendingCommand)>(Pending.Count); + foreach (var kvp in Pending) + { + if (kvp.Value.IsExecuting) + { + continue; + } + + kvp.Value.IsExecuting = true; + ready.Add((kvp.Key, kvp.Value)); + } + + if (ready.Count == 0) + { + UnhookUpdateIfIdle(); + return; + } + } + + foreach (var (id, pending) in ready) + { + ProcessCommand(id, pending); + } + } + + private static void ProcessCommand(string id, PendingCommand pending) + { + if (pending.CancellationToken.IsCancellationRequested) + { + RemovePending(id, pending); + pending.TrySetCanceled(); + return; + } + + string commandText = pending.CommandJson?.Trim(); + if (string.IsNullOrEmpty(commandText)) + { + pending.TrySetResult(SerializeError("Empty command received")); + RemovePending(id, pending); + return; + } + + if (string.Equals(commandText, "ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" } + }; + pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); + RemovePending(id, pending); + return; + } + + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText + }; + pending.TrySetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + RemovePending(id, pending); + return; + } + + try + { + var command = JsonConvert.DeserializeObject(commandText); + if (command == null) + { + pending.TrySetResult(SerializeError("Command deserialized to null", "Unknown", commandText)); + RemovePending(id, pending); + return; + } + + if (string.IsNullOrWhiteSpace(command.type)) + { + pending.TrySetResult(SerializeError("Command type cannot be empty")); + RemovePending(id, pending); + return; + } + + if (string.Equals(command.type, "ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" } + }; + pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); + RemovePending(id, pending); + return; + } + + var parameters = command.@params ?? new JObject(); + var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource); + + if (result == null) + { + // Async command – cleanup after completion on next editor frame to preserve order. + pending.CompletionSource.Task.ContinueWith(_ => + { + EditorApplication.delayCall += () => RemovePending(id, pending); + }, TaskScheduler.Default); + return; + } + + var response = new { status = "success", result }; + pending.TrySetResult(JsonConvert.SerializeObject(response)); + RemovePending(id, pending); + } + catch (Exception ex) + { + McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + pending.TrySetResult(SerializeError(ex.Message, "Unknown (error during processing)", ex.StackTrace)); + RemovePending(id, pending); + } + } + + private static void CancelPending(string id, CancellationToken token) + { + PendingCommand pending = null; + lock (PendingLock) + { + if (Pending.Remove(id, out pending)) + { + UnhookUpdateIfIdle(); + } + } + + pending?.TrySetCanceled(); + pending?.Dispose(); + } + + private static void RemovePending(string id, PendingCommand pending) + { + lock (PendingLock) + { + Pending.Remove(id); + UnhookUpdateIfIdle(); + } + + pending.Dispose(); + } + + private static string SerializeError(string message, string commandType = null, string stackTrace = null) + { + var errorResponse = new + { + status = "error", + error = message, + command = commandType ?? "Unknown", + stackTrace + }; + return JsonConvert.SerializeObject(errorResponse); + } + + private static bool IsValidJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if ((text.StartsWith("{") && text.EndsWith("}")) || (text.StartsWith("[") && text.EndsWith("]"))) + { + try + { + JToken.Parse(text); + return true; + } + catch + { + return false; + } + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta new file mode 100644 index 0000000..494c010 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27407cc9c1ea0412d80b9f8964a5a29d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/TransportManager.cs b/MCPForUnity/Editor/Services/Transport/TransportManager.cs new file mode 100644 index 0000000..7a6afe9 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs @@ -0,0 +1,106 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport.Transports; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Coordinates the active transport client and exposes lifecycle helpers. + /// + public class TransportManager + { + private IMcpTransportClient _active; + private TransportMode? _activeMode; + private Func _webSocketFactory; + private Func _stdioFactory; + + public TransportManager() + { + Configure( + () => new WebSocketTransportClient(MCPServiceLocator.ToolDiscovery), + () => new StdioTransportClient()); + } + + public IMcpTransportClient ActiveTransport => _active; + public TransportMode? ActiveMode => _activeMode; + + public void Configure( + Func webSocketFactory, + Func stdioFactory) + { + _webSocketFactory = webSocketFactory ?? throw new ArgumentNullException(nameof(webSocketFactory)); + _stdioFactory = stdioFactory ?? throw new ArgumentNullException(nameof(stdioFactory)); + } + + public async Task StartAsync(TransportMode mode) + { + await StopAsync(); + + IMcpTransportClient next = mode switch + { + TransportMode.Stdio => _stdioFactory(), + TransportMode.Http => _webSocketFactory(), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode") + } ?? throw new InvalidOperationException($"Factory returned null for transport mode {mode}"); + + bool started = await next.StartAsync(); + if (!started) + { + await next.StopAsync(); + _active = null; + _activeMode = null; + return false; + } + + _active = next; + _activeMode = mode; + return true; + } + + public async Task StopAsync() + { + if (_active != null) + { + try + { + await _active.StopAsync(); + } + catch (Exception ex) + { + McpLog.Warn($"Error while stopping transport {_active.TransportName}: {ex.Message}"); + } + finally + { + _active = null; + _activeMode = null; + } + } + } + + public async Task VerifyAsync() + { + if (_active == null) + { + return false; + } + return await _active.VerifyAsync(); + } + + public TransportState GetState() + { + if (_active == null) + { + return TransportState.Disconnected(_activeMode?.ToString()?.ToLowerInvariant() ?? "unknown", "Transport not started"); + } + + return _active.State ?? TransportState.Disconnected(_active.TransportName, "No state reported"); + } + } + + public enum TransportMode + { + Http, + Stdio + } +} diff --git a/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta b/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta new file mode 100644 index 0000000..7adde46 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65fc8ff4c9efb4fc98a0910ba7ca8b02 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/TransportState.cs b/MCPForUnity/Editor/Services/Transport/TransportState.cs new file mode 100644 index 0000000..7fb6f20 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportState.cs @@ -0,0 +1,52 @@ +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Lightweight snapshot of a transport's runtime status for editor UI and diagnostics. + /// + public sealed class TransportState + { + public bool IsConnected { get; } + public string TransportName { get; } + public int? Port { get; } + public string SessionId { get; } + public string Details { get; } + public string Error { get; } + + private TransportState( + bool isConnected, + string transportName, + int? port, + string sessionId, + string details, + string error) + { + IsConnected = isConnected; + TransportName = transportName; + Port = port; + SessionId = sessionId; + Details = details; + Error = error; + } + + public static TransportState Connected( + string transportName, + int? port = null, + string sessionId = null, + string details = null) + => new TransportState(true, transportName, port, sessionId, details, null); + + public static TransportState Disconnected( + string transportName, + string error = null, + int? port = null) + => new TransportState(false, transportName, port, null, null, error); + + public TransportState WithError(string error) => new TransportState( + IsConnected, + TransportName, + Port, + SessionId, + Details, + error); + } +} diff --git a/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta b/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta new file mode 100644 index 0000000..5c592ce --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67ab8e43f6a804698bb5b216cdef0645 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/Transports.meta b/MCPForUnity/Editor/Services/Transport/Transports.meta new file mode 100644 index 0000000..878b705 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d467a63b6fad42fa975c731af4b83b3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs similarity index 64% rename from MCPForUnity/Editor/MCPForUnityBridge.cs rename to MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 23537b8..5cc1585 100644 --- a/MCPForUnity/Editor/MCPForUnityBridge.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -11,17 +11,15 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.Prefabs; +using MCPForUnity.Editor.Services.Transport; -namespace MCPForUnity.Editor +namespace MCPForUnity.Editor.Services.Transport.Transports { - - /// - /// Outbound message structure for the writer thread - /// class Outbound { public byte[] Payload; @@ -29,24 +27,22 @@ namespace MCPForUnity.Editor public int? ReqId; } - /// - /// Queued command structure for main thread processing - /// class QueuedCommand { public string CommandJson; public TaskCompletionSource Tcs; public bool IsExecuting; } + [InitializeOnLoad] - public static partial class MCPForUnityBridge + public static class StdioBridgeHost { private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); - private static readonly System.Collections.Generic.HashSet activeClients = new(); + private static readonly HashSet activeClients = new(); private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); private static CancellationTokenSource cts; private static Task listenerTask; @@ -59,19 +55,18 @@ namespace MCPForUnity.Editor private static int heartbeatSeq = 0; private static Dictionary commandQueue = new(); private static int mainThreadId; - private static int currentUnityPort = 6400; // Dynamic port, starts with default + private static int currentUnityPort = 6400; private static bool isAutoConnectMode = false; - private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads - private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients + private static bool shouldRestartAfterReload = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; + private const int FrameIOTimeoutMs = 30000; - // IO diagnostics private static long _ioSeq = 0; private static void IoInfo(string s) { McpLog.Info(s, always: false); } - // Debug helpers private static bool IsDebugEnabled() { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { return false; } } private static void LogBreadcrumb(string stage) @@ -86,28 +81,21 @@ namespace MCPForUnity.Editor public static int GetCurrentPort() => currentUnityPort; public static bool IsAutoConnectMode() => isAutoConnectMode; - /// - /// Start with Auto-Connect mode - discovers new port and saves it - /// public static void StartAutoConnect() { - Stop(); // Stop current connection + Stop(); try { - // Prefer stored project port and start using the robust Start() path (with retries/options) currentUnityPort = PortManager.GetPortWithFallback(); Start(); isAutoConnectMode = true; - // Record telemetry for bridge startup TelemetryHelper.RecordBridgeStartup(); } catch (Exception ex) { McpLog.Error($"Auto-connect failed: {ex.Message}"); - - // Record telemetry for connection failure TelemetryHelper.RecordBridgeConnection(false, ex.Message); throw; } @@ -132,11 +120,9 @@ namespace MCPForUnity.Editor return Directory.Exists(fullPath); } - static MCPForUnityBridge() + static StdioBridgeHost() { - // Record the main thread ID for safe thread checks try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } - // Start single writer thread for framed responses try { var writerThread = new Thread(() => @@ -148,10 +134,6 @@ namespace MCPForUnity.Editor long seq = Interlocked.Increment(ref _ioSeq); IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); var sw = System.Diagnostics.Stopwatch.StartNew(); - // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, - // writes are performed inline there. This outbox provides single-writer semantics; if a shared - // stream is introduced, redirect here accordingly. - // No-op: actual write happens in client loop using WriteFrameAsync sw.Stop(); IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); } @@ -166,36 +148,35 @@ namespace MCPForUnity.Editor } catch { } - // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env - // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } - // Defer start until the editor is idle and not compiling - ScheduleInitRetry(); - // Add a safety net update hook in case delayCall is missed during reload churn - if (!ensureUpdateHooked) + if (ShouldAutoStartBridge()) { - ensureUpdateHooked = true; - EditorApplication.update += EnsureStartedOnEditorIdle; + ScheduleInitRetry(); + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } } EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; - // Also coalesce play mode transitions into a deferred init - EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); + EditorApplication.playModeStateChanged += _ => + { + if (ShouldAutoStartBridge()) + { + ScheduleInitRetry(); + } + }; } - /// - /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. - /// This prevents repeated restarts during script compilation that cause port hopping. - /// private static void InitializeAfterCompilation() { initScheduled = false; - // Play-mode friendly: allow starting in play mode; only defer while compiling if (IsCompiling()) { ScheduleInitRetry(); @@ -207,7 +188,6 @@ namespace MCPForUnity.Editor Start(); if (!isRunning) { - // If a race prevented start, retry later ScheduleInitRetry(); } } @@ -220,28 +200,35 @@ namespace MCPForUnity.Editor return; } initScheduled = true; - // Debounce: start ~200ms after the last trigger nextStartAt = EditorApplication.timeSinceStartup + 0.20f; - // Ensure the update pump is active if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } - // Keep the original delayCall as a secondary path EditorApplication.delayCall += InitializeAfterCompilation; } - // Safety net: ensure the bridge starts shortly after domain reload when editor is idle + private static bool ShouldAutoStartBridge() + { + try + { + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + return !useHttpTransport; + } + catch + { + return true; + } + } + private static void EnsureStartedOnEditorIdle() { - // Do nothing while compiling if (IsCompiling()) { return; } - // If already running, remove the hook if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; @@ -249,7 +236,6 @@ namespace MCPForUnity.Editor return; } - // Debounced start: wait until the scheduled time if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) { return; @@ -263,7 +249,6 @@ namespace MCPForUnity.Editor isStarting = true; try { - // Attempt start; if it succeeds, remove the hook to avoid overhead Start(); } finally @@ -277,7 +262,6 @@ namespace MCPForUnity.Editor } } - // Helper to check compilation status across Unity versions private static bool IsCompiling() { if (EditorApplication.isCompiling) @@ -286,7 +270,7 @@ namespace MCPForUnity.Editor } try { - System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (prop != null) { @@ -301,25 +285,21 @@ namespace MCPForUnity.Editor { lock (startStopLock) { - // Don't restart if already running on a working port if (isRunning && listener != null) { if (IsDebugEnabled()) { - McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}"); + McpLog.Info($"StdioBridgeHost already running on port {currentUnityPort}"); } return; } Stop(); - // Attempt fast bind with stored-port preference (sticky per-project) try { - // Always consult PortManager first so we prefer the persisted project port currentUnityPort = PortManager.GetPortWithFallback(); - // Breadcrumb: Start LogBreadcrumb("Start"); const int maxImmediateRetries = 3; @@ -342,14 +322,12 @@ namespace MCPForUnity.Editor } catch { } #endif - // Minimize TIME_WAIT by sending RST on close try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { - // Ignore if not supported on platform } listener.Start(); break; @@ -362,12 +340,26 @@ namespace MCPForUnity.Editor } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) { - // Port is occupied by another instance, get a new available port int oldPort = currentUnityPort; + + // Before switching ports, give the old one a brief chance to release if it looks like ours + try + { + if (PortManager.IsPortUsedByMCPForUnity(oldPort)) + { + const int waitStepMs = 100; + int waited = 0; + while (waited < 300 && !PortManager.IsPortAvailable(oldPort)) + { + Thread.Sleep(waitStepMs); + waited += waitStepMs; + } + } + } + catch { } + currentUnityPort = PortManager.GetPortWithFallback(); - // GetPortWithFallback() may return the same port if it became available during wait - // or a different port if switching to an alternative if (IsDebugEnabled()) { if (currentUnityPort == oldPort) @@ -408,21 +400,18 @@ namespace MCPForUnity.Editor isRunning = true; isAutoConnectMode = false; string platform = Application.platform.ToString(); - string serverVer = ReadInstalledServerVersionSafe(); - McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); - // Start background listener with cooperative cancellation + string serverVer = AssetPathUtility.GetPackageVersion(); + McpLog.Info($"StdioBridgeHost started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); CommandRegistry.Initialize(); EditorApplication.update += ProcessCommands; - // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting += Stop; } catch { } - // Write initial heartbeat immediately heartbeatSeq++; WriteHeartbeat(false, "ready"); nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; @@ -446,10 +435,8 @@ namespace MCPForUnity.Editor try { - // Mark as stopping early to avoid accept logging during disposal isRunning = false; - // Quiesce background listener quickly var cancel = cts; cts = null; try { cancel?.Cancel(); } catch { } @@ -457,17 +444,15 @@ namespace MCPForUnity.Editor try { listener?.Stop(); } catch { } listener = null; - // Capture background task to wait briefly outside the lock toWait = listenerTask; listenerTask = null; } catch (Exception ex) { - McpLog.Error($"Error stopping MCPForUnityBridge: {ex.Message}"); + McpLog.Error($"Error stopping StdioBridgeHost: {ex.Message}"); } } - // Proactively close all active client sockets to unblock any pending reads TcpClient[] toClose; lock (clientsLock) { @@ -479,19 +464,16 @@ namespace MCPForUnity.Editor try { c.Close(); } catch { } } - // Give the background loop a short window to exit without blocking the editor if (toWait != null) { try { toWait.Wait(100); } catch { } } - // Now unhook editor events safely try { EditorApplication.update -= ProcessCommands; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } - // Clean up status file when Unity stops try { string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); @@ -507,7 +489,7 @@ namespace MCPForUnity.Editor if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}"); } - if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped."); + if (IsDebugEnabled()) McpLog.Info("StdioBridgeHost stopped."); } private static async Task ListenerLoopAsync(CancellationToken token) @@ -517,22 +499,18 @@ namespace MCPForUnity.Editor try { TcpClient client = await listener.AcceptTcpClientAsync(); - // Enable basic socket keepalive client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true ); - // Set longer receive timeout to prevent quick disconnections - client.ReceiveTimeout = 60000; // 60 seconds + client.ReceiveTimeout = 60000; - // Fire and forget each client connection _ = Task.Run(() => HandleClientAsync(client, token), token); } catch (ObjectDisposedException) { - // Listener was disposed during stop/reload; exit quietly if (!isRunning || token.IsCancellationRequested) { break; @@ -560,7 +538,6 @@ namespace MCPForUnity.Editor lock (clientsLock) { activeClients.Add(client); } try { - // Framed I/O only; legacy mode removed try { if (IsDebugEnabled()) @@ -570,7 +547,6 @@ namespace MCPForUnity.Editor } } catch { } - // Strict framing: always require FRAMING=1 and frame all I/O try { client.NoDelay = true; @@ -584,21 +560,20 @@ namespace MCPForUnity.Editor #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); #else - await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif if (IsDebugEnabled()) McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}"); - return; // abort this client + return; } while (isRunning && !token.IsCancellationRequested) { try { - // Strict framed mode only: enforced framed I/O for this connection string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); try @@ -613,12 +588,9 @@ namespace MCPForUnity.Editor string commandId = Guid.NewGuid().ToString(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Special handling for ping command to avoid JSON parsing if (commandText.Trim() == "ping") { - // Direct response to ping without going through JSON parsing byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( - /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); await WriteFrameAsync(stream, pingResponseBytes); @@ -635,7 +607,6 @@ namespace MCPForUnity.Editor }; } - // Wait for the handler to produce a response, but do not block indefinitely string response; try { @@ -643,13 +614,11 @@ namespace MCPForUnity.Editor var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); if (completed == tcs.Task) { - // Got a result from the handler respCts.Cancel(); response = tcs.Task.Result; } else { - // Timeout: return a structured error so the client can recover var timeoutResponse = new { status = "error", @@ -672,8 +641,7 @@ namespace MCPForUnity.Editor { try { McpLog.Info("[MCP] sending framed response", always: false); } catch { } } - // Crash-proof and self-reporting writer logs (direct write to this client's stream) - long seq = System.Threading.Interlocked.Increment(ref _ioSeq); + long seq = Interlocked.Increment(ref _ioSeq); byte[] responseBytes; try { @@ -701,12 +669,11 @@ namespace MCPForUnity.Editor } catch (Exception ex) { - // Treat common disconnects/timeouts as benign; only surface hard errors string msg = ex.Message ?? string.Empty; bool isBenign = msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 - || ex is System.IO.IOException; + || ex is IOException; if (isBenign) { if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false); @@ -726,8 +693,7 @@ namespace MCPForUnity.Editor } } - // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks - private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) + private static async Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) { byte[] buffer = new byte[count]; int offset = 0; @@ -740,10 +706,9 @@ namespace MCPForUnity.Editor ? Timeout.Infinite : timeoutMs - (int)stopwatch.ElapsedMilliseconds; - // If a finite timeout is configured and already elapsed, fail immediately if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) { - throw new System.IO.IOException("Read timed out"); + throw new IOException("Read timed out"); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); @@ -761,34 +726,34 @@ namespace MCPForUnity.Editor #endif if (read == 0) { - throw new System.IO.IOException("Connection closed before reading expected bytes"); + throw new IOException("Connection closed before reading expected bytes"); } offset += read; } catch (OperationCanceledException) when (!cancel.IsCancellationRequested) { - throw new System.IO.IOException("Read timed out"); + throw new IOException("Read timed out"); } } return buffer; } - private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) + private static Task WriteFrameAsync(NetworkStream stream, byte[] payload) { using var cts = new CancellationTokenSource(FrameIOTimeoutMs); - await WriteFrameAsync(stream, payload, cts.Token); + return WriteFrameAsync(stream, payload, cts.Token); } - private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + private static async Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) { if (payload == null) { - throw new System.ArgumentNullException(nameof(payload)); + throw new ArgumentNullException(nameof(payload)); } if ((ulong)payload.LongLength > MaxFrameBytes) { - throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); + throw new IOException($"Frame too large: {payload.LongLength}"); } byte[] header = new byte[8]; WriteUInt64BigEndian(header, (ulong)payload.LongLength); @@ -801,19 +766,19 @@ namespace MCPForUnity.Editor #endif } - private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) + private static async Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) { byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); ulong payloadLen = ReadUInt64BigEndian(header); if (payloadLen > MaxFrameBytes) { - throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + throw new IOException($"Invalid framed length: {payloadLen}"); } if (payloadLen == 0UL) - throw new System.IO.IOException("Zero-length frames are not allowed"); + throw new IOException("Zero-length frames are not allowed"); if (payloadLen > int.MaxValue) { - throw new System.IO.IOException("Frame too large for buffer"); + throw new IOException("Frame too large for buffer"); } int count = (int)payloadLen; byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); @@ -837,7 +802,7 @@ namespace MCPForUnity.Editor { if (dest == null || dest.Length < 8) { - throw new System.ArgumentException("Destination buffer too small for UInt64"); + throw new ArgumentException("Destination buffer too small for UInt64"); } dest[0] = (byte)(value >> 56); dest[1] = (byte)(value >> 48); @@ -852,10 +817,9 @@ namespace MCPForUnity.Editor private static void ProcessCommands() { if (!isRunning) return; - if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard + if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; try { - // Heartbeat without holding the queue lock double now = EditorApplication.timeSinceStartup; if (now >= nextHeartbeatAt) { @@ -863,7 +827,6 @@ namespace MCPForUnity.Editor nextHeartbeatAt = now + 0.5f; } - // Snapshot under lock, then process outside to reduce contention List<(string id, QueuedCommand command)> work; lock (lockObj) { @@ -884,120 +847,47 @@ namespace MCPForUnity.Editor string commandText = queuedCommand.CommandJson; TaskCompletionSource tcs = queuedCommand.Tcs; - try + if (string.IsNullOrWhiteSpace(commandText)) { - // Special case handling - if (string.IsNullOrEmpty(commandText)) - { - var emptyResponse = new - { - status = "error", - error = "Empty command received", - }; - tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); - // Remove quickly under lock - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Trim the command text to remove any whitespace - commandText = commandText.Trim(); - - // Non-JSON direct commands handling (like ping) - if (commandText == "ping") - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Check if the command is valid JSON before attempting to deserialize - if (!IsValidJson(commandText)) - { - var invalidJsonResponse = new - { - status = "error", - error = "Invalid JSON format", - receivedText = commandText.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Normal JSON command processing - Command command = JsonConvert.DeserializeObject(commandText); - - if (command == null) - { - var nullCommandResponse = new - { - status = "error", - error = "Command deserialized to null", - details = "The command was valid JSON but could not be deserialized to a Command object", - }; - tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); - } - else - { - // Use JObject for parameters as handlers expect this - JObject paramsObject = command.@params ?? new JObject(); - - // Execute command (may be sync or async) - object result = CommandRegistry.ExecuteCommand(command.type, paramsObject, tcs); - - // If result is null, it means async execution - TCS will be completed by the awaited task - // In this case, DON'T remove from queue yet, DON'T complete TCS - if (result == null) - { - // Async command - the task continuation will complete the TCS - // Setup cleanup when TCS completes - schedule on next frame to avoid race conditions - string asyncCommandId = id; - _ = tcs.Task.ContinueWith(_ => - { - // Use EditorApplication.delayCall to schedule cleanup on main thread, next frame - EditorApplication.delayCall += () => - { - lock (lockObj) - { - commandQueue.Remove(asyncCommandId); - } - }; - }); - continue; // Skip the queue removal below - } - - // Synchronous result - complete TCS now - var response = new { status = "success", result }; - tcs.SetResult(JsonConvert.SerializeObject(response)); - } - } - catch (Exception ex) - { - McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); - - var response = new + var emptyResponse = new { status = "error", - error = ex.Message, - commandType = "Unknown (error during processing)", - receivedText = commandText?.Length > 50 + error = "Empty command received", + }; + tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + commandText = commandText.Trim(); + if (commandText == "ping") + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" }, + }; + tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText, }; - string responseJson = JsonConvert.SerializeObject(response); - tcs.SetResult(responseJson); + tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; } - // Remove from queue (only for sync commands - async ones skip with 'continue' above) - lock (lockObj) { commandQueue.Remove(id); } + ExecuteQueuedCommand(id, commandText, tcs); } } finally @@ -1006,20 +896,60 @@ namespace MCPForUnity.Editor } } - // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. - // Returns null on timeout or error; caller should provide a fallback error response. + private static void ExecuteQueuedCommand(string commandId, string payload, TaskCompletionSource completionSource) + { + async void Runner() + { + try + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + string response = await TransportCommandDispatcher.ExecuteCommandJsonAsync(payload, cts.Token).ConfigureAwait(true); + completionSource.TrySetResult(response); + } + catch (OperationCanceledException) + { + var timeoutResponse = new + { + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + completionSource.TrySetResult(JsonConvert.SerializeObject(timeoutResponse)); + } + catch (Exception ex) + { + McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + var response = new + { + status = "error", + error = ex.Message, + receivedText = payload?.Length > 50 + ? payload[..50] + "..." + : payload, + }; + completionSource.TrySetResult(JsonConvert.SerializeObject(response)); + } + finally + { + lock (lockObj) + { + commandQueue.Remove(commandId); + } + } + } + + Runner(); + } + private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) { if (func == null) return null; try { - // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. if (mainThreadId == 0) { try { return func(); } catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } } - // If we are already on the main thread, execute directly to avoid deadlocks try { if (Thread.CurrentThread.ManagedThreadId == mainThreadId) @@ -1048,11 +978,10 @@ namespace MCPForUnity.Editor } }; - // Wait for completion with timeout (Editor thread will pump delayCall) bool completed = tcs.Task.Wait(timeoutMs); if (!completed) { - return null; // timeout + return null; } if (captured != null) { @@ -1066,7 +995,6 @@ namespace MCPForUnity.Editor } } - // Helper method to check if a string is valid JSON private static bool IsValidJson(string text) { if (string.IsNullOrWhiteSpace(text)) @@ -1077,9 +1005,9 @@ namespace MCPForUnity.Editor text = text.Trim(); if ( (text.StartsWith("{") && text.EndsWith("}")) - || // Object + || (text.StartsWith("[") && text.EndsWith("]")) - ) // Array + ) { try { @@ -1095,113 +1023,45 @@ namespace MCPForUnity.Editor return false; } - private static string ExecuteCommand(Command command) - { - try - { - if (string.IsNullOrEmpty(command.type)) - { - var errorResponse = new - { - status = "error", - error = "Command type cannot be empty", - details = "A valid command type is required for processing", - }; - return JsonConvert.SerializeObject(errorResponse); - } - - // Handle ping command for connection verification - if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - return JsonConvert.SerializeObject(pingResponse); - } - - // Use JObject for parameters as the new handlers likely expect this - JObject paramsObject = command.@params ?? new JObject(); - object result = CommandRegistry.GetHandler(command.type)(paramsObject); - - // Standard success response format - var response = new { status = "success", result }; - return JsonConvert.SerializeObject(response); - } - catch (Exception ex) - { - // Log the detailed error in Unity for debugging - McpLog.Error($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"); - - // Standard error response format - var response = new - { - status = "error", - error = ex.Message, // Provide the specific error message - command = command?.type ?? "Unknown", // Include the command type if available - stackTrace = ex.StackTrace, // Include stack trace for detailed debugging - paramsSummary = command?.@params != null - ? GetParamsSummary(command.@params) - : "No parameters", // Summarize parameters for context - }; - return JsonConvert.SerializeObject(response); - } - } - - private static object HandleManageScene(JObject paramsObject) - { - try - { - if (IsDebugEnabled()) McpLog.Info("[MCP] manage_scene: dispatching to main thread"); - var sw = System.Diagnostics.Stopwatch.StartNew(); - var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); - sw.Stop(); - if (IsDebugEnabled()) McpLog.Info($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); - return r ?? Response.Error("manage_scene returned null (timeout or error)"); - } - catch (Exception ex) - { - return Response.Error($"manage_scene dispatch error: {ex.Message}"); - } - } - - // Helper method to get a summary of parameters for error reporting - private static string GetParamsSummary(JObject @params) - { - try - { - return @params == null || !@params.HasValues - ? "No parameters" - : string.Join( - ", ", - @params - .Properties() - .Select(static p => - $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" - ) - ); - } - catch - { - return "Could not summarize parameters"; - } - } - - // Heartbeat/status helpers private static void OnBeforeAssemblyReload() { - // Stop cleanly before reload so sockets close and clients see 'reloading' + if (isRunning) + { + shouldRestartAfterReload = true; + } try { Stop(); } catch { } - // Avoid file I/O or heavy work here } private static void OnAfterAssemblyReload() { - // Will be overwritten by Start(), but mark as alive quickly WriteHeartbeat(false, "idle"); LogBreadcrumb("Idle"); - // Schedule a safe restart after reload to avoid races during compilation + bool shouldResume = ShouldAutoStartBridge() || shouldRestartAfterReload; + if (shouldRestartAfterReload) + { + shouldRestartAfterReload = false; + } + if (!shouldResume) + { + return; + } + + // If we're not compiling, try to bring the bridge up immediately to avoid depending on editor focus. + if (!IsCompiling()) + { + try + { + Start(); + return; // Successful immediate start; no need to schedule a delayed retry + } + catch (Exception ex) + { + // Fall through to delayed retry if immediate start fails + McpLog.Warn($"Immediate STDIO bridge restart after reload failed: {ex.Message}"); + } + } + + // Fallback path when compiling or if immediate start failed ScheduleInitRetry(); } @@ -1209,7 +1069,6 @@ namespace MCPForUnity.Editor { try { - // Allow override of status directory (useful in CI/containers) string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); if (string.IsNullOrWhiteSpace(dir)) { @@ -1218,14 +1077,12 @@ namespace MCPForUnity.Editor Directory.CreateDirectory(dir); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); - // Extract project name from path string projectName = "Unknown"; try { string projectPath = Application.dataPath; if (!string.IsNullOrEmpty(projectPath)) { - // Remove trailing /Assets or \Assets projectPath = projectPath.TrimEnd('/', '\\'); if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) { @@ -1255,26 +1112,9 @@ namespace MCPForUnity.Editor } catch (Exception) { - // Best-effort only } } - private static string ReadInstalledServerVersionSafe() - { - try - { - string serverSrc = ServerInstaller.GetServerPath(); - string verFile = Path.Combine(serverSrc, "server_version.txt"); - if (File.Exists(verFile)) - { - string v = File.ReadAllText(verFile)?.Trim(); - if (!string.IsNullOrEmpty(v)) return v; - } - } - catch { } - return "unknown"; - } - private static string ComputeProjectHash(string input) { try diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta new file mode 100644 index 0000000..b836856 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fd295cefe518e438693c12e9c7f37488 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs new file mode 100644 index 0000000..ea3ed1a --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + /// + /// Adapts the existing TCP bridge into the transport abstraction. + /// + public class StdioTransportClient : IMcpTransportClient + { + private TransportState _state = TransportState.Disconnected("stdio"); + + public bool IsConnected => StdioBridgeHost.IsRunning; + public string TransportName => "stdio"; + public TransportState State => _state; + + public Task StartAsync() + { + try + { + StdioBridgeHost.StartAutoConnect(); + _state = TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort()); + return Task.FromResult(true); + } + catch (Exception ex) + { + _state = TransportState.Disconnected("stdio", ex.Message); + return Task.FromResult(false); + } + } + + public Task StopAsync() + { + StdioBridgeHost.Stop(); + _state = TransportState.Disconnected("stdio"); + return Task.CompletedTask; + } + + public Task VerifyAsync() + { + bool running = StdioBridgeHost.IsRunning; + _state = running + ? TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort()) + : TransportState.Disconnected("stdio", "Bridge not running"); + return Task.FromResult(running); + } + + } +} diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta new file mode 100644 index 0000000..f4ac1ab --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2743f3468d5f433dbf2220f0838d8d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs new file mode 100644 index 0000000..14ff725 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -0,0 +1,690 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + /// + /// Maintains a persistent WebSocket connection to the MCP server plugin hub. + /// Handles registration, keep-alives, and command dispatch back into Unity via + /// . + /// + public class WebSocketTransportClient : IMcpTransportClient, IDisposable + { + private const string TransportDisplayName = "websocket"; + private static readonly TimeSpan[] ReconnectSchedule = + { + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }; + + private static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30); + + private readonly IToolDiscoveryService _toolDiscoveryService; + private ClientWebSocket _socket; + private CancellationTokenSource _lifecycleCts; + private CancellationTokenSource _connectionCts; + private Task _receiveTask; + private Task _keepAliveTask; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + private Uri _endpointUri; + private string _sessionId; + private string _projectHash; + private string _projectName; + private string _unityVersion; + private TimeSpan _keepAliveInterval = DefaultKeepAliveInterval; + private TimeSpan _socketKeepAliveInterval = DefaultKeepAliveInterval; + private volatile bool _isConnected; + private int _isReconnectingFlag; + private TransportState _state = TransportState.Disconnected(TransportDisplayName, "Transport not started"); + private bool _disposed; + + public WebSocketTransportClient(IToolDiscoveryService toolDiscoveryService = null) + { + _toolDiscoveryService = toolDiscoveryService; + } + + public bool IsConnected => _isConnected; + public string TransportName => TransportDisplayName; + public TransportState State => _state; + + public async Task StartAsync() + { + // Capture identity values on the main thread before any async context switching + _projectName = ProjectIdentityUtility.GetProjectName(); + _projectHash = ProjectIdentityUtility.GetProjectHash(); + _unityVersion = Application.unityVersion; + + await StopAsync(); + + _lifecycleCts = new CancellationTokenSource(); + _endpointUri = BuildWebSocketUri(HttpEndpointUtility.GetBaseUrl()); + _sessionId = null; + + if (!await EstablishConnectionAsync(_lifecycleCts.Token)) + { + await StopAsync(); + return false; + } + + // State is connected but session ID might be pending until 'registered' message + _state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString()); + _isConnected = true; + return true; + } + + public async Task StopAsync() + { + if (_lifecycleCts == null) + { + return; + } + + try + { + _lifecycleCts.Cancel(); + } + catch { } + + await StopConnectionLoopsAsync().ConfigureAwait(false); + + if (_socket != null) + { + try + { + if (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.CloseReceived) + { + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Shutdown", CancellationToken.None).ConfigureAwait(false); + } + } + catch { } + finally + { + _socket.Dispose(); + _socket = null; + } + } + + _isConnected = false; + _state = TransportState.Disconnected(TransportDisplayName); + + _lifecycleCts.Dispose(); + _lifecycleCts = null; + } + + public async Task VerifyAsync() + { + if (_socket == null || _socket.State != WebSocketState.Open) + { + return false; + } + + if (_lifecycleCts == null) + { + return false; + } + + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_lifecycleCts.Token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + await SendPongAsync(timeoutCts.Token).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Verify ping failed: {ex.Message}"); + return false; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + // Ensure background loops are stopped before disposing shared resources + StopAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Dispose failed to stop cleanly: {ex.Message}"); + } + + _sendLock?.Dispose(); + _socket?.Dispose(); + _lifecycleCts?.Dispose(); + _disposed = true; + } + + private async Task EstablishConnectionAsync(CancellationToken token) + { + await StopConnectionLoopsAsync().ConfigureAwait(false); + + _connectionCts?.Dispose(); + _connectionCts = CancellationTokenSource.CreateLinkedTokenSource(token); + CancellationToken connectionToken = _connectionCts.Token; + + _socket?.Dispose(); + _socket = new ClientWebSocket(); + _socket.Options.KeepAliveInterval = _socketKeepAliveInterval; + + try + { + await _socket.ConnectAsync(_endpointUri, connectionToken).ConfigureAwait(false); + } + catch (Exception ex) + { + McpLog.Error($"[WebSocket] Connection failed: {ex.Message}"); + return false; + } + + StartBackgroundLoops(connectionToken); + + try + { + await SendRegisterAsync(connectionToken).ConfigureAwait(false); + } + catch (Exception ex) + { + McpLog.Error($"[WebSocket] Registration failed: {ex.Message}"); + return false; + } + + return true; + } + + /// + /// Stops the connection loops and disposes of the connection CTS. + /// Particularly useful when reconnecting, we want to ensure that background loops are cancelled correctly before starting new oens + /// + /// Whether to await the receive and keep alive tasks before disposing. + private async Task StopConnectionLoopsAsync(bool awaitTasks = true) + { + if (_connectionCts != null && !_connectionCts.IsCancellationRequested) + { + try { _connectionCts.Cancel(); } catch { } + } + + if (_receiveTask != null) + { + if (awaitTasks) + { + try { await _receiveTask.ConfigureAwait(false); } catch { } + _receiveTask = null; + } + else if (_receiveTask.IsCompleted) + { + _receiveTask = null; + } + } + + if (_keepAliveTask != null) + { + if (awaitTasks) + { + try { await _keepAliveTask.ConfigureAwait(false); } catch { } + _keepAliveTask = null; + } + else if (_keepAliveTask.IsCompleted) + { + _keepAliveTask = null; + } + } + + if (_connectionCts != null) + { + _connectionCts.Dispose(); + _connectionCts = null; + } + } + + private void StartBackgroundLoops(CancellationToken token) + { + if ((_receiveTask != null && !_receiveTask.IsCompleted) || (_keepAliveTask != null && !_keepAliveTask.IsCompleted)) + { + return; + } + + _receiveTask = Task.Run(() => ReceiveLoopAsync(token), CancellationToken.None); + _keepAliveTask = Task.Run(() => KeepAliveLoopAsync(token), CancellationToken.None); + } + + private async Task ReceiveLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + string message = await ReceiveMessageAsync(token).ConfigureAwait(false); + if (message == null) + { + continue; + } + await HandleMessageAsync(message, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (WebSocketException wse) + { + McpLog.Warn($"[WebSocket] Receive loop error: {wse.Message}"); + await HandleSocketClosureAsync(wse.Message).ConfigureAwait(false); + break; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Unexpected receive error: {ex.Message}"); + await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false); + break; + } + } + } + + private async Task ReceiveMessageAsync(CancellationToken token) + { + if (_socket == null) + { + return null; + } + + byte[] rentedBuffer = ArrayPool.Shared.Rent(8192); + var buffer = new ArraySegment(rentedBuffer); + using var ms = new MemoryStream(8192); + + try + { + while (!token.IsCancellationRequested) + { + WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, token).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + await HandleSocketClosureAsync(result.CloseStatusDescription ?? "Server closed connection").ConfigureAwait(false); + return null; + } + + if (result.Count > 0) + { + ms.Write(buffer.Array!, buffer.Offset, result.Count); + } + + if (result.EndOfMessage) + { + break; + } + } + + if (ms.Length == 0) + { + return null; + } + + return Encoding.UTF8.GetString(ms.ToArray()); + } + finally + { + ArrayPool.Shared.Return(rentedBuffer); + } + } + + private async Task HandleMessageAsync(string message, CancellationToken token) + { + JObject payload; + try + { + payload = JObject.Parse(message); + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Invalid JSON payload: {ex.Message}"); + return; + } + + string messageType = payload.Value("type") ?? string.Empty; + + switch (messageType) + { + case "welcome": + ApplyWelcome(payload); + break; + case "registered": + await HandleRegisteredAsync(payload, token).ConfigureAwait(false); + break; + case "execute": + await HandleExecuteAsync(payload, token).ConfigureAwait(false); + break; + case "ping": + await SendPongAsync(token).ConfigureAwait(false); + break; + default: + // No-op for unrecognised types (keep-alives, telemetry, etc.) + break; + } + } + + private void ApplyWelcome(JObject payload) + { + int? keepAliveSeconds = payload.Value("keepAliveInterval"); + if (keepAliveSeconds.HasValue && keepAliveSeconds.Value > 0) + { + _keepAliveInterval = TimeSpan.FromSeconds(keepAliveSeconds.Value); + _socketKeepAliveInterval = _keepAliveInterval; + } + + int? serverTimeoutSeconds = payload.Value("serverTimeout"); + if (serverTimeoutSeconds.HasValue) + { + int sourceSeconds = keepAliveSeconds ?? serverTimeoutSeconds.Value; + int safeSeconds = Math.Max(5, Math.Min(serverTimeoutSeconds.Value, sourceSeconds)); + _socketKeepAliveInterval = TimeSpan.FromSeconds(safeSeconds); + } + } + + private async Task HandleRegisteredAsync(JObject payload, CancellationToken token) + { + string newSessionId = payload.Value("session_id"); + if (!string.IsNullOrEmpty(newSessionId)) + { + _sessionId = newSessionId; + ProjectIdentityUtility.SetSessionId(_sessionId); + _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); + McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}"); + + await SendRegisterToolsAsync(token).ConfigureAwait(false); + } + } + + private async Task SendRegisterToolsAsync(CancellationToken token) + { + if (_toolDiscoveryService == null) return; + + var tools = _toolDiscoveryService.DiscoverAllTools(); + var toolsArray = new JArray(); + + foreach (var tool in tools) + { + var toolObj = new JObject + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["structured_output"] = tool.StructuredOutput, + ["requires_polling"] = tool.RequiresPolling, + ["poll_action"] = tool.PollAction + }; + + var paramsArray = new JArray(); + if (tool.Parameters != null) + { + foreach (var p in tool.Parameters) + { + paramsArray.Add(new JObject + { + ["name"] = p.Name, + ["description"] = p.Description, + ["type"] = p.Type, + ["required"] = p.Required, + ["default_value"] = p.DefaultValue + }); + } + } + toolObj["parameters"] = paramsArray; + toolsArray.Add(toolObj); + } + + var payload = new JObject + { + ["type"] = "register_tools", + ["tools"] = toolsArray + }; + + await SendJsonAsync(payload, token).ConfigureAwait(false); + McpLog.Info($"[WebSocket] Sent {tools.Count} tools registration"); + } + + private async Task HandleExecuteAsync(JObject payload, CancellationToken token) + { + string commandId = payload.Value("id"); + string commandName = payload.Value("name"); + JObject parameters = payload.Value("params") ?? new JObject(); + int timeoutSeconds = payload.Value("timeout") ?? (int)DefaultCommandTimeout.TotalSeconds; + + if (string.IsNullOrEmpty(commandId) || string.IsNullOrEmpty(commandName)) + { + McpLog.Warn("[WebSocket] Invalid execute payload (missing id or name)"); + return; + } + + var commandEnvelope = new JObject + { + ["type"] = commandName, + ["params"] = parameters + }; + + string responseJson; + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))); + responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + responseJson = JsonConvert.SerializeObject(new + { + status = "error", + error = $"Command '{commandName}' timed out after {timeoutSeconds} seconds" + }); + } + catch (Exception ex) + { + responseJson = JsonConvert.SerializeObject(new + { + status = "error", + error = ex.Message + }); + } + + JToken resultToken; + try + { + resultToken = JToken.Parse(responseJson); + } + catch + { + resultToken = new JObject + { + ["status"] = "error", + ["error"] = "Invalid response payload" + }; + } + + var responsePayload = new JObject + { + ["type"] = "command_result", + ["id"] = commandId, + ["result"] = resultToken + }; + + await SendJsonAsync(responsePayload, token).ConfigureAwait(false); + } + + private async Task KeepAliveLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_keepAliveInterval, token).ConfigureAwait(false); + if (_socket == null || _socket.State != WebSocketState.Open) + { + break; + } + await SendPongAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Keep-alive failed: {ex.Message}"); + await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false); + break; + } + } + } + + private async Task SendRegisterAsync(CancellationToken token) + { + var registerPayload = new JObject + { + ["type"] = "register", + // session_id is now server-authoritative; omitted here or sent as null + ["project_name"] = _projectName, + ["project_hash"] = _projectHash, + ["unity_version"] = _unityVersion + }; + + await SendJsonAsync(registerPayload, token).ConfigureAwait(false); + } + + private Task SendPongAsync(CancellationToken token) + { + var payload = new JObject + { + ["type"] = "pong", + }; + return SendJsonAsync(payload, token); + } + + private async Task SendJsonAsync(JObject payload, CancellationToken token) + { + if (_socket == null) + { + throw new InvalidOperationException("WebSocket is not initialised"); + } + + string json = payload.ToString(Formatting.None); + byte[] bytes = Encoding.UTF8.GetBytes(json); + var buffer = new ArraySegment(bytes); + + await _sendLock.WaitAsync(token).ConfigureAwait(false); + try + { + if (_socket.State != WebSocketState.Open) + { + throw new InvalidOperationException("WebSocket is not open"); + } + + await _socket.SendAsync(buffer, WebSocketMessageType.Text, true, token).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + private async Task HandleSocketClosureAsync(string reason) + { + if (_lifecycleCts == null || _lifecycleCts.IsCancellationRequested) + { + return; + } + + if (Interlocked.CompareExchange(ref _isReconnectingFlag, 1, 0) != 0) + { + return; + } + + _isConnected = false; + _state = _state.WithError(reason ?? "Connection closed"); + McpLog.Warn($"[WebSocket] Connection closed: {reason}"); + + await StopConnectionLoopsAsync(awaitTasks: false).ConfigureAwait(false); + + _ = Task.Run(() => AttemptReconnectAsync(_lifecycleCts.Token), CancellationToken.None); + } + + private async Task AttemptReconnectAsync(CancellationToken token) + { + try + { + await StopConnectionLoopsAsync().ConfigureAwait(false); + + foreach (TimeSpan delay in ReconnectSchedule) + { + if (token.IsCancellationRequested) + { + return; + } + + if (delay > TimeSpan.Zero) + { + try { await Task.Delay(delay, token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + + if (await EstablishConnectionAsync(token).ConfigureAwait(false)) + { + _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); + _isConnected = true; + McpLog.Info("[WebSocket] Reconnected to MCP server"); + return; + } + } + } + finally + { + Interlocked.Exchange(ref _isReconnectingFlag, 0); + } + + _state = TransportState.Disconnected(TransportDisplayName, "Failed to reconnect"); + } + + private static Uri BuildWebSocketUri(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + { + baseUrl = "http://localhost:8080"; + } + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var httpUri)) + { + throw new InvalidOperationException($"Invalid MCP base URL: {baseUrl}"); + } + + string scheme = httpUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws"; + string builder = $"{scheme}://{httpUri.Authority}"; + if (!string.IsNullOrEmpty(httpUri.AbsolutePath) && httpUri.AbsolutePath != "/") + { + builder += httpUri.AbsolutePath.TrimEnd('/'); + } + + builder += "/hub/plugin"; + + return new Uri(builder); + } + } +} diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta new file mode 100644 index 0000000..91b98e0 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 044c8f7beb4af4a77a14d677190c21dc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Setup/SetupWizard.cs b/MCPForUnity/Editor/Setup/SetupWindowService.cs similarity index 71% rename from MCPForUnity/Editor/Setup/SetupWizard.cs rename to MCPForUnity/Editor/Setup/SetupWindowService.cs index 7bb77de..b1c5ed6 100644 --- a/MCPForUnity/Editor/Setup/SetupWizard.cs +++ b/MCPForUnity/Editor/Setup/SetupWindowService.cs @@ -3,33 +3,34 @@ using MCPForUnity.Editor.Dependencies; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Windows; +using MCPForUnity.Editor.Constants; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Setup { /// - /// Handles automatic triggering of the setup wizard + /// Handles automatic triggering of the MCP setup window and exposes menu entry points /// [InitializeOnLoad] - public static class SetupWizard + public static class SetupWindowService { - private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; - private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; + private const string SETUP_COMPLETED_KEY = EditorPrefKeys.SetupCompleted; + private const string SETUP_DISMISSED_KEY = EditorPrefKeys.SetupDismissed; private static bool _hasCheckedThisSession = false; - static SetupWizard() + static SetupWindowService() { // Skip in batch mode if (Application.isBatchMode) return; - // Show setup wizard on package import + // Show Setup Window on package import EditorApplication.delayCall += CheckSetupNeeded; } /// - /// Check if setup wizard should be shown + /// Check if Setup Window should be shown /// private static void CheckSetupNeeded() { @@ -44,17 +45,17 @@ namespace MCPForUnity.Editor.Setup bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); - // Only show setup wizard if it hasn't been completed or dismissed before + // Only show Setup Window if it hasn't been completed or dismissed before if (!(setupCompleted || setupDismissed)) { - McpLog.Info("Package imported - showing setup wizard", always: false); + McpLog.Info("Package imported - showing Setup Window", always: false); var dependencyResult = DependencyManager.CheckAllDependencies(); - EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); + EditorApplication.delayCall += () => ShowSetupWindow(dependencyResult); } else { - McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); + McpLog.Info("Setup Window skipped - previously completed or dismissed", always: false); } } catch (Exception ex) @@ -64,18 +65,18 @@ namespace MCPForUnity.Editor.Setup } /// - /// Show the setup wizard window + /// Show the setup window /// - public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) + public static void ShowSetupWindow(DependencyCheckResult dependencyResult = null) { try { dependencyResult ??= DependencyManager.CheckAllDependencies(); - SetupWizardWindow.ShowWindow(dependencyResult); + MCPSetupWindow.ShowWindow(dependencyResult); } catch (Exception ex) { - McpLog.Error($"Error showing setup wizard: {ex.Message}"); + McpLog.Error($"Error showing setup window: {ex.Message}"); } } diff --git a/MCPForUnity/Editor/Setup/SetupWizard.cs.meta b/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta similarity index 100% rename from MCPForUnity/Editor/Setup/SetupWizard.cs.meta rename to MCPForUnity/Editor/Setup/SetupWindowService.cs.meta diff --git a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs deleted file mode 100644 index 61c0ef9..0000000 --- a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs +++ /dev/null @@ -1,344 +0,0 @@ -using System; -using System.Linq; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Models; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Setup -{ - /// - /// Setup wizard window for guiding users through dependency installation - /// - public class SetupWizardWindow : EditorWindow - { - private DependencyCheckResult _dependencyResult; - private Vector2 _scrollPosition; - private int _currentStep = 0; - - private readonly string[] _stepTitles = { - "Setup", - "Complete" - }; - - public static void ShowWindow(DependencyCheckResult dependencyResult = null) - { - var window = GetWindow("MCP for Unity Setup"); - window.minSize = new Vector2(500, 400); - window.maxSize = new Vector2(800, 600); - window._dependencyResult = dependencyResult ?? DependencyManager.CheckAllDependencies(); - window.Show(); - } - - private void OnEnable() - { - if (_dependencyResult == null) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - } - - private void OnGUI() - { - DrawHeader(); - DrawProgressBar(); - - _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); - - switch (_currentStep) - { - case 0: DrawSetupStep(); break; - case 1: DrawCompleteStep(); break; - } - - EditorGUILayout.EndScrollView(); - - DrawFooter(); - } - - private void DrawHeader() - { - EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); - GUILayout.Label("MCP for Unity Setup Wizard", EditorStyles.boldLabel); - GUILayout.FlexibleSpace(); - GUILayout.Label($"Step {_currentStep + 1} of {_stepTitles.Length}"); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - // Step title - var titleStyle = new GUIStyle(EditorStyles.largeLabel) - { - fontSize = 16, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(_stepTitles[_currentStep], titleStyle); - EditorGUILayout.Space(); - } - - private void DrawProgressBar() - { - var rect = EditorGUILayout.GetControlRect(false, 4); - var progress = (_currentStep + 1) / (float)_stepTitles.Length; - EditorGUI.ProgressBar(rect, progress, ""); - EditorGUILayout.Space(); - } - - private void DrawSetupStep() - { - // Welcome section - DrawSectionTitle("MCP for Unity Setup"); - - EditorGUILayout.LabelField( - "This wizard will help you set up MCP for Unity to connect AI assistants with your Unity Editor.", - EditorStyles.wordWrappedLabel - ); - EditorGUILayout.Space(); - - // Dependency check section - EditorGUILayout.BeginHorizontal(); - DrawSectionTitle("System Check", 14); - GUILayout.FlexibleSpace(); - if (GUILayout.Button("Refresh", GUILayout.Width(60), GUILayout.Height(20))) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - EditorGUILayout.EndHorizontal(); - - // Show simplified dependency status - foreach (var dep in _dependencyResult.Dependencies) - { - DrawSimpleDependencyStatus(dep); - } - - // Overall status and installation guidance - EditorGUILayout.Space(); - if (!_dependencyResult.IsSystemReady) - { - // Only show critical warnings when dependencies are actually missing - EditorGUILayout.HelpBox( - "\u26A0 Missing Dependencies: MCP for Unity requires Python 3.10+ and UV package manager to function properly.", - MessageType.Warning - ); - - EditorGUILayout.Space(); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - DrawErrorStatus("Installation Required"); - - var recommendations = DependencyManager.GetInstallationRecommendations(); - EditorGUILayout.LabelField(recommendations, EditorStyles.wordWrappedLabel); - - EditorGUILayout.Space(); - if (GUILayout.Button("Open Installation Links", GUILayout.Height(25))) - { - OpenInstallationUrls(); - } - EditorGUILayout.EndVertical(); - } - else - { - DrawSuccessStatus("System Ready"); - EditorGUILayout.LabelField("All requirements are met. You can proceed to configure your AI clients.", EditorStyles.wordWrappedLabel); - } - } - - private void DrawCompleteStep() - { - DrawSectionTitle("Setup Complete"); - - // Refresh dependency check with caching to avoid heavy operations on every repaint - if (_dependencyResult == null || (DateTime.UtcNow - _dependencyResult.CheckedAt).TotalSeconds > 2) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - - if (_dependencyResult.IsSystemReady) - { - DrawSuccessStatus("MCP for Unity Ready!"); - - EditorGUILayout.HelpBox( - "🎉 MCP for Unity is now set up and ready to use!\n\n" + - "• Dependencies verified\n" + - "• MCP server ready\n" + - "• Client configuration accessible", - MessageType.Info - ); - - EditorGUILayout.Space(); - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Documentation", GUILayout.Height(30))) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp"); - } - if (GUILayout.Button("Client Settings", GUILayout.Height(30))) - { - Windows.MCPForUnityEditorWindow.ShowWindow(); - } - EditorGUILayout.EndHorizontal(); - } - else - { - DrawErrorStatus("Setup Incomplete - Package Non-Functional"); - - EditorGUILayout.HelpBox( - "🚨 MCP for Unity CANNOT work - dependencies still missing!\n\n" + - "Install ALL required dependencies before the package will function.", - MessageType.Error - ); - - var missingDeps = _dependencyResult.GetMissingRequired(); - if (missingDeps.Count > 0) - { - EditorGUILayout.Space(); - EditorGUILayout.LabelField("Still Missing:", EditorStyles.boldLabel); - foreach (var dep in missingDeps) - { - EditorGUILayout.LabelField($"✗ {dep.Name}", EditorStyles.label); - } - } - - EditorGUILayout.Space(); - if (GUILayout.Button("Go Back to Setup", GUILayout.Height(30))) - { - _currentStep = 0; - } - } - } - - // Helper methods for consistent UI components - private void DrawSectionTitle(string title, int fontSize = 16) - { - var titleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = fontSize, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(title, titleStyle); - EditorGUILayout.Space(); - } - - private void DrawSuccessStatus(string message) - { - var originalColor = GUI.color; - GUI.color = Color.green; - EditorGUILayout.LabelField($"✓ {message}", EditorStyles.boldLabel); - GUI.color = originalColor; - EditorGUILayout.Space(); - } - - private void DrawErrorStatus(string message) - { - var originalColor = GUI.color; - GUI.color = Color.red; - EditorGUILayout.LabelField($"✗ {message}", EditorStyles.boldLabel); - GUI.color = originalColor; - EditorGUILayout.Space(); - } - - private void DrawSimpleDependencyStatus(DependencyStatus dep) - { - EditorGUILayout.BeginHorizontal(); - - var statusIcon = dep.IsAvailable ? "✓" : "✗"; - var statusColor = dep.IsAvailable ? Color.green : Color.red; - - var originalColor = GUI.color; - GUI.color = statusColor; - GUILayout.Label(statusIcon, GUILayout.Width(20)); - EditorGUILayout.LabelField(dep.Name, EditorStyles.boldLabel); - GUI.color = originalColor; - - if (!dep.IsAvailable && !string.IsNullOrEmpty(dep.ErrorMessage)) - { - EditorGUILayout.LabelField($"({dep.ErrorMessage})", EditorStyles.miniLabel); - } - - EditorGUILayout.EndHorizontal(); - } - - private void DrawFooter() - { - EditorGUILayout.Space(); - EditorGUILayout.BeginHorizontal(); - - // Back button - GUI.enabled = _currentStep > 0; - if (GUILayout.Button("Back", GUILayout.Width(60))) - { - _currentStep--; - } - - GUILayout.FlexibleSpace(); - - // Skip button - if (GUILayout.Button("Skip", GUILayout.Width(60))) - { - bool dismiss = EditorUtility.DisplayDialog( - "Skip Setup", - "\u26A0 Skipping setup will leave MCP for Unity non-functional!\n\n" + - "You can restart setup from: Window > MCP for Unity > Setup Wizard (Required)", - "Skip Anyway", - "Cancel" - ); - - if (dismiss) - { - SetupWizard.MarkSetupDismissed(); - Close(); - } - } - - // Next/Done button - GUI.enabled = true; - string buttonText = _currentStep == _stepTitles.Length - 1 ? "Done" : "Next"; - - if (GUILayout.Button(buttonText, GUILayout.Width(80))) - { - if (_currentStep == _stepTitles.Length - 1) - { - SetupWizard.MarkSetupCompleted(); - Close(); - } - else - { - _currentStep++; - } - } - - GUI.enabled = true; - EditorGUILayout.EndHorizontal(); - } - - private void OpenInstallationUrls() - { - var (pythonUrl, uvUrl) = DependencyManager.GetInstallationUrls(); - - bool openPython = EditorUtility.DisplayDialog( - "Open Installation URLs", - "Open Python installation page?", - "Yes", - "No" - ); - - if (openPython) - { - Application.OpenURL(pythonUrl); - } - - bool openUV = EditorUtility.DisplayDialog( - "Open Installation URLs", - "Open UV installation page?", - "Yes", - "No" - ); - - if (openUV) - { - Application.OpenURL(uvUrl); - } - } - } -} diff --git a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta deleted file mode 100644 index 5361de3..0000000 --- a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 45678901234abcdef0123456789abcde -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs b/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs index 503295b..2ce1c84 100644 --- a/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs +++ b/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs @@ -6,7 +6,7 @@ using UnityEditor; namespace MCPForUnity.Editor.Tools { - [McpForUnityTool("execute_menu_item")] + [McpForUnityTool("execute_menu_item", AutoRegister = false)] public static class ExecuteMenuItem { // Basic blacklist to prevent execution of disruptive menu items. @@ -22,12 +22,12 @@ namespace MCPForUnity.Editor.Tools string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); if (string.IsNullOrWhiteSpace(menuPath)) { - return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + return new ErrorResponse("Required parameter 'menu_path' or 'menuPath' is missing or empty."); } if (_menuPathBlacklist.Contains(menuPath)) { - return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons."); + return new ErrorResponse($"Execution of menu item '{menuPath}' is blocked for safety reasons."); } try @@ -36,14 +36,14 @@ namespace MCPForUnity.Editor.Tools if (!executed) { McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); - return Response.Error($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + return new ErrorResponse($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); } - return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); + return new SuccessResponse($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); } catch (Exception e) { McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}"); - return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}"); + return new ErrorResponse($"Error setting up execution for menu item '{menuPath}': {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 46be0ef..36480a2 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -22,7 +22,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles asset management operations within the Unity project. /// - [McpForUnityTool("manage_asset")] + [McpForUnityTool("manage_asset", AutoRegister = false)] public static class ManageAsset { // --- Main Handler --- @@ -48,14 +48,14 @@ namespace MCPForUnity.Editor.Tools string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } // Check if the action is valid before switching if (!ValidActions.Contains(action)) { string validActionsList = string.Join(", ", ValidActions); - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: {validActionsList}" ); } @@ -112,7 +112,7 @@ namespace MCPForUnity.Editor.Tools default: // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. string validActionsListDefault = string.Join(", ", ValidActions); - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" ); } @@ -120,7 +120,7 @@ namespace MCPForUnity.Editor.Tools catch (Exception e) { Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); - return Response.Error( + return new ErrorResponse( $"Internal error processing action '{action}' on '{path}': {e.Message}" ); } @@ -131,10 +131,10 @@ namespace MCPForUnity.Editor.Tools private static object ReimportAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for reimport."); + return new ErrorResponse("'path' is required for reimport."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -152,11 +152,11 @@ namespace MCPForUnity.Editor.Tools AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh - return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); + return new SuccessResponse($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); } catch (Exception e) { - return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to reimport asset '{fullPath}': {e.Message}"); } } @@ -167,9 +167,9 @@ namespace MCPForUnity.Editor.Tools JObject properties = @params["properties"] as JObject; if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for create."); + return new ErrorResponse("'path' is required for create."); if (string.IsNullOrEmpty(assetType)) - return Response.Error("'assetType' is required for create."); + return new ErrorResponse("'assetType' is required for create."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string directory = Path.GetDirectoryName(fullPath); @@ -182,7 +182,7 @@ namespace MCPForUnity.Editor.Tools } if (AssetExists(fullPath)) - return Response.Error($"Asset already exists at path: {fullPath}"); + return new ErrorResponse($"Asset already exists at path: {fullPath}"); try { @@ -205,7 +205,7 @@ namespace MCPForUnity.Editor.Tools ?? Shader.Find("Standard") ?? Shader.Find("Unlit/Color"); if (shader == null) - return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); + return new ErrorResponse($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); var mat = new Material(shader); if (properties != null) @@ -225,7 +225,7 @@ namespace MCPForUnity.Editor.Tools { string scriptClassName = properties?["scriptClass"]?.ToString(); if (string.IsNullOrEmpty(scriptClassName)) - return Response.Error( + return new ErrorResponse( "'scriptClass' property required when creating ScriptableObject asset." ); @@ -238,7 +238,7 @@ namespace MCPForUnity.Editor.Tools var reason = scriptType == null ? (string.IsNullOrEmpty(error) ? "Type not found." : error) : "Type found but does not inherit from ScriptableObject."; - return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); + return new ErrorResponse($"Script class '{scriptClassName}' invalid: {reason}"); } ScriptableObject so = ScriptableObject.CreateInstance(scriptType); @@ -250,7 +250,7 @@ namespace MCPForUnity.Editor.Tools { // Creating prefabs usually involves saving an existing GameObject hierarchy. // A common pattern is to create an empty GameObject, configure it, and then save it. - return Response.Error( + return new ErrorResponse( "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." ); // Example (conceptual): @@ -265,7 +265,7 @@ namespace MCPForUnity.Editor.Tools // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); - return Response.Error( + return new ErrorResponse( $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." ); } @@ -275,28 +275,28 @@ namespace MCPForUnity.Editor.Tools && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) ) // Check if it wasn't a folder and asset wasn't created { - return Response.Error( + return new ErrorResponse( $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." ); } AssetDatabase.SaveAssets(); // AssetDatabase.Refresh(); // CreateAsset often handles refresh - return Response.Success( + return new SuccessResponse( $"Asset '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { - return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to create asset at '{fullPath}': {e.Message}"); } } private static object CreateFolder(string path) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for create_folder."); + return new ErrorResponse("'path' is required for create_folder."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string parentDir = Path.GetDirectoryName(fullPath); string folderName = Path.GetFileName(fullPath); @@ -306,14 +306,14 @@ namespace MCPForUnity.Editor.Tools // Check if it's actually a folder already if (AssetDatabase.IsValidFolder(fullPath)) { - return Response.Success( + return new SuccessResponse( $"Folder already exists at path: {fullPath}", GetAssetData(fullPath) ); } else { - return Response.Error( + return new ErrorResponse( $"An asset (not a folder) already exists at path: {fullPath}" ); } @@ -331,33 +331,33 @@ namespace MCPForUnity.Editor.Tools string guid = AssetDatabase.CreateFolder(parentDir, folderName); if (string.IsNullOrEmpty(guid)) { - return Response.Error( + return new ErrorResponse( $"Failed to create folder '{fullPath}'. Check logs and permissions." ); } // AssetDatabase.Refresh(); // CreateFolder usually handles refresh - return Response.Success( + return new SuccessResponse( $"Folder '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { - return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to create folder '{fullPath}': {e.Message}"); } } private static object ModifyAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for modify."); + return new ErrorResponse("'path' is required for modify."); if (properties == null || !properties.HasValues) - return Response.Error("'properties' are required for modify."); + return new ErrorResponse("'properties' are required for modify."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -365,7 +365,7 @@ namespace MCPForUnity.Editor.Tools fullPath ); if (asset == null) - return Response.Error($"Failed to load asset at path: {fullPath}"); + return new ErrorResponse($"Failed to load asset at path: {fullPath}"); bool modified = false; // Flag to track if any changes were made @@ -484,7 +484,7 @@ namespace MCPForUnity.Editor.Tools AssetDatabase.SaveAssets(); // Refresh might be needed in some edge cases, but SaveAssets usually covers it. // AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath) ); @@ -492,11 +492,11 @@ namespace MCPForUnity.Editor.Tools else { // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. - return Response.Success( + return new SuccessResponse( $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", GetAssetData(fullPath) ); - // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + // Previous message: return new SuccessResponse($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); } } catch (Exception e) @@ -504,17 +504,17 @@ namespace MCPForUnity.Editor.Tools // Log the detailed error internally Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); // Return a user-friendly error message - return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to modify asset '{fullPath}': {e.Message}"); } } private static object DeleteAsset(string path) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for delete."); + return new ErrorResponse("'path' is required for delete."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -522,30 +522,30 @@ namespace MCPForUnity.Editor.Tools if (success) { // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh - return Response.Success($"Asset '{fullPath}' deleted successfully."); + return new SuccessResponse($"Asset '{fullPath}' deleted successfully."); } else { // This might happen if the file couldn't be deleted (e.g., locked) - return Response.Error( + return new ErrorResponse( $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." ); } } catch (Exception e) { - return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Error deleting asset '{fullPath}': {e.Message}"); } } private static object DuplicateAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for duplicate."); + return new ErrorResponse("'path' is required for duplicate."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(sourcePath)) - return Response.Error($"Source asset not found at path: {sourcePath}"); + return new ErrorResponse($"Source asset not found at path: {sourcePath}"); string destPath; if (string.IsNullOrEmpty(destinationPath)) @@ -557,7 +557,7 @@ namespace MCPForUnity.Editor.Tools { destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (AssetExists(destPath)) - return Response.Error($"Asset already exists at destination path: {destPath}"); + return new ErrorResponse($"Asset already exists at destination path: {destPath}"); // Ensure destination directory exists EnsureDirectoryExists(Path.GetDirectoryName(destPath)); } @@ -568,38 +568,38 @@ namespace MCPForUnity.Editor.Tools if (success) { // AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Asset '{sourcePath}' duplicated to '{destPath}'.", GetAssetData(destPath) ); } else { - return Response.Error( + return new ErrorResponse( $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { - return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); + return new ErrorResponse($"Error duplicating asset '{sourcePath}': {e.Message}"); } } private static object MoveOrRenameAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for move/rename."); + return new ErrorResponse("'path' is required for move/rename."); if (string.IsNullOrEmpty(destinationPath)) - return Response.Error("'destination' path is required for move/rename."); + return new ErrorResponse("'destination' path is required for move/rename."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (!AssetExists(sourcePath)) - return Response.Error($"Source asset not found at path: {sourcePath}"); + return new ErrorResponse($"Source asset not found at path: {sourcePath}"); if (AssetExists(destPath)) - return Response.Error( + return new ErrorResponse( $"An asset already exists at the destination path: {destPath}" ); @@ -612,7 +612,7 @@ namespace MCPForUnity.Editor.Tools string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); if (!string.IsNullOrEmpty(error)) { - return Response.Error( + return new ErrorResponse( $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" ); } @@ -621,7 +621,7 @@ namespace MCPForUnity.Editor.Tools if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success { // AssetDatabase.Refresh(); // MoveAsset usually handles refresh - return Response.Success( + return new SuccessResponse( $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", GetAssetData(destPath) ); @@ -629,14 +629,14 @@ namespace MCPForUnity.Editor.Tools else { // This case might not be reachable if ValidateMoveAsset passes, but good to have - return Response.Error( + return new ErrorResponse( $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { - return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); + return new ErrorResponse($"Error moving/renaming asset '{sourcePath}': {e.Message}"); } } @@ -728,7 +728,7 @@ namespace MCPForUnity.Editor.Tools int startIndex = (pageNumber - 1) * pageSize; var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); - return Response.Success( + return new SuccessResponse( $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", new { @@ -741,28 +741,28 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error($"Error searching assets: {e.Message}"); + return new ErrorResponse($"Error searching assets: {e.Message}"); } } private static object GetAssetInfo(string path, bool generatePreview) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for get_info."); + return new ErrorResponse("'path' is required for get_info."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { - return Response.Success( + return new SuccessResponse( "Asset info retrieved.", GetAssetData(fullPath, generatePreview) ); } catch (Exception e) { - return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Error getting info for asset '{fullPath}': {e.Message}"); } } @@ -775,12 +775,12 @@ namespace MCPForUnity.Editor.Tools { // 1. Validate input path if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for get_components."); + return new ErrorResponse("'path' is required for get_components."); // 2. Sanitize and check existence string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -789,7 +789,7 @@ namespace MCPForUnity.Editor.Tools fullPath ); if (asset == null) - return Response.Error($"Failed to load asset at path: {fullPath}"); + return new ErrorResponse($"Failed to load asset at path: {fullPath}"); // 4. Check if it's a GameObject (Prefabs load as GameObjects) GameObject gameObject = asset as GameObject; @@ -801,11 +801,11 @@ namespace MCPForUnity.Editor.Tools { // If the asset itself *is* a component, maybe return just its info? // This is an edge case. Let's stick to GameObjects for now. - return Response.Error( + return new ErrorResponse( $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." ); } - return Response.Error( + return new ErrorResponse( $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." ); } @@ -825,7 +825,7 @@ namespace MCPForUnity.Editor.Tools .ToList(); // Explicit cast for clarity if needed // 7. Return success response - return Response.Success( + return new SuccessResponse( $"Found {componentList.Count} component(s) on asset '{fullPath}'.", componentList ); @@ -835,7 +835,7 @@ namespace MCPForUnity.Editor.Tools Debug.LogError( $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" ); - return Response.Error( + return new ErrorResponse( $"Error getting components for asset '{fullPath}': {e.Message}" ); } @@ -1051,113 +1051,113 @@ namespace MCPForUnity.Editor.Tools } } - // --- Flexible direct property assignment --- - // Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." } - // while retaining backward compatibility with the structured keys above. - // This iterates all top-level keys except the reserved structured ones and applies them - // if they match known shader properties. - var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; + // --- Flexible direct property assignment --- + // Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." } + // while retaining backward compatibility with the structured keys above. + // This iterates all top-level keys except the reserved structured ones and applies them + // if they match known shader properties. + var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; - // Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness) - string ResolvePropertyName(string name) - { - if (string.IsNullOrEmpty(name)) return name; - string[] candidates; - var lower = name.ToLowerInvariant(); - switch (lower) - { - case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; - case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; - case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; - case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; - case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; - case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; - // Friendly names → shader property names - case "metallic": candidates = new[] { "_Metallic" }; break; - case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; - case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; - default: candidates = new[] { name }; break; // keep original as-is - } - foreach (var candidate in candidates) - { - if (mat.HasProperty(candidate)) return candidate; - } - return name; // fall back to original - } + // Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness) + string ResolvePropertyName(string name) + { + if (string.IsNullOrEmpty(name)) return name; + string[] candidates; + var lower = name.ToLowerInvariant(); + switch (lower) + { + case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; + case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; + case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; + case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; + case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; + case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + // Friendly names → shader property names + case "metallic": candidates = new[] { "_Metallic" }; break; + case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; + default: candidates = new[] { name }; break; // keep original as-is + } + foreach (var candidate in candidates) + { + if (mat.HasProperty(candidate)) return candidate; + } + return name; // fall back to original + } - foreach (var prop in properties.Properties()) - { - if (reservedKeys.Contains(prop.Name)) continue; - string shaderProp = ResolvePropertyName(prop.Name); - JToken v = prop.Value; + foreach (var prop in properties.Properties()) + { + if (reservedKeys.Contains(prop.Name)) continue; + string shaderProp = ResolvePropertyName(prop.Name); + JToken v = prop.Value; - // Color: numeric array [r,g,b,(a)] - if (v is JArray arr && arr.Count >= 3 && arr.All(t => t.Type == JTokenType.Float || t.Type == JTokenType.Integer)) - { - if (mat.HasProperty(shaderProp)) - { - try - { - var c = new Color( - arr[0].ToObject(), - arr[1].ToObject(), - arr[2].ToObject(), - arr.Count > 3 ? arr[3].ToObject() : 1f - ); - if (mat.GetColor(shaderProp) != c) - { - mat.SetColor(shaderProp, c); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning($"Error setting color '{shaderProp}': {ex.Message}"); - } - } - continue; - } + // Color: numeric array [r,g,b,(a)] + if (v is JArray arr && arr.Count >= 3 && arr.All(t => t.Type == JTokenType.Float || t.Type == JTokenType.Integer)) + { + if (mat.HasProperty(shaderProp)) + { + try + { + var c = new Color( + arr[0].ToObject(), + arr[1].ToObject(), + arr[2].ToObject(), + arr.Count > 3 ? arr[3].ToObject() : 1f + ); + if (mat.GetColor(shaderProp) != c) + { + mat.SetColor(shaderProp, c); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error setting color '{shaderProp}': {ex.Message}"); + } + } + continue; + } - // Float: single number - if (v.Type == JTokenType.Float || v.Type == JTokenType.Integer) - { - if (mat.HasProperty(shaderProp)) - { - try - { - float f = v.ToObject(); - if (!Mathf.Approximately(mat.GetFloat(shaderProp), f)) - { - mat.SetFloat(shaderProp, f); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning($"Error setting float '{shaderProp}': {ex.Message}"); - } - } - continue; - } + // Float: single number + if (v.Type == JTokenType.Float || v.Type == JTokenType.Integer) + { + if (mat.HasProperty(shaderProp)) + { + try + { + float f = v.ToObject(); + if (!Mathf.Approximately(mat.GetFloat(shaderProp), f)) + { + mat.SetFloat(shaderProp, f); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error setting float '{shaderProp}': {ex.Message}"); + } + } + continue; + } - // Texture: string path - if (v.Type == JTokenType.String) - { - string texPath = v.ToString(); - if (!string.IsNullOrEmpty(texPath) && mat.HasProperty(shaderProp)) - { - var tex = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(texPath)); - if (tex != null && mat.GetTexture(shaderProp) != tex) - { - mat.SetTexture(shaderProp, tex); - modified = true; - } - } - continue; - } - } + // Texture: string path + if (v.Type == JTokenType.String) + { + string texPath = v.ToString(); + if (!string.IsNullOrEmpty(texPath) && mat.HasProperty(shaderProp)) + { + var tex = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(texPath)); + if (tex != null && mat.GetTexture(shaderProp) != tex) + { + mat.SetTexture(shaderProp, tex); + modified = true; + } + } + continue; + } + } - // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) + // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) return modified; } diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 87e4186..1cdcb8b 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -10,7 +10,7 @@ namespace MCPForUnity.Editor.Tools /// Handles editor control actions including play mode control, tool selection, /// and tag/layer management. For reading editor state, use MCP resources instead. /// - [McpForUnityTool("manage_editor")] + [McpForUnityTool("manage_editor", AutoRegister = false)] public static class ManageEditor { // Constant for starting user layer index @@ -32,7 +32,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } // Route action @@ -45,13 +45,13 @@ namespace MCPForUnity.Editor.Tools if (!EditorApplication.isPlaying) { EditorApplication.isPlaying = true; - return Response.Success("Entered play mode."); + return new SuccessResponse("Entered play mode."); } - return Response.Success("Already in play mode."); + return new SuccessResponse("Already in play mode."); } catch (Exception e) { - return Response.Error($"Error entering play mode: {e.Message}"); + return new ErrorResponse($"Error entering play mode: {e.Message}"); } case "pause": try @@ -59,15 +59,15 @@ namespace MCPForUnity.Editor.Tools if (EditorApplication.isPlaying) { EditorApplication.isPaused = !EditorApplication.isPaused; - return Response.Success( + return new SuccessResponse( EditorApplication.isPaused ? "Game paused." : "Game resumed." ); } - return Response.Error("Cannot pause/resume: Not in play mode."); + return new ErrorResponse("Cannot pause/resume: Not in play mode."); } catch (Exception e) { - return Response.Error($"Error pausing/resuming game: {e.Message}"); + return new ErrorResponse($"Error pausing/resuming game: {e.Message}"); } case "stop": try @@ -75,52 +75,52 @@ namespace MCPForUnity.Editor.Tools if (EditorApplication.isPlaying) { EditorApplication.isPlaying = false; - return Response.Success("Exited play mode."); + return new SuccessResponse("Exited play mode."); } - return Response.Success("Already stopped (not in play mode)."); + return new SuccessResponse("Already stopped (not in play mode)."); } catch (Exception e) { - return Response.Error($"Error stopping play mode: {e.Message}"); + return new ErrorResponse($"Error stopping play mode: {e.Message}"); } // Tool Control case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) - return Response.Error("'toolName' parameter required for set_active_tool."); + return new ErrorResponse("'toolName' parameter required for set_active_tool."); return SetActiveTool(toolName); // Tag Management case "add_tag": if (string.IsNullOrEmpty(tagName)) - return Response.Error("'tagName' parameter required for add_tag."); + return new ErrorResponse("'tagName' parameter required for add_tag."); return AddTag(tagName); case "remove_tag": if (string.IsNullOrEmpty(tagName)) - return Response.Error("'tagName' parameter required for remove_tag."); + return new ErrorResponse("'tagName' parameter required for remove_tag."); return RemoveTag(tagName); // Layer Management case "add_layer": if (string.IsNullOrEmpty(layerName)) - return Response.Error("'layerName' parameter required for add_layer."); + return new ErrorResponse("'layerName' parameter required for add_layer."); return AddLayer(layerName); case "remove_layer": if (string.IsNullOrEmpty(layerName)) - return Response.Error("'layerName' parameter required for remove_layer."); + return new ErrorResponse("'layerName' parameter required for remove_layer."); return RemoveLayer(layerName); // --- Settings (Example) --- // case "set_resolution": // int? width = @params["width"]?.ToObject(); // int? height = @params["height"]?.ToObject(); - // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); + // if (!width.HasValue || !height.HasValue) return new ErrorResponse("'width' and 'height' parameters required."); // return SetGameViewResolution(width.Value, height.Value); // case "set_quality": // // Handle string name or int index // return SetQualityLevel(@params["qualityLevel"]); default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } @@ -139,11 +139,11 @@ namespace MCPForUnity.Editor.Tools if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool { UnityEditor.Tools.current = targetTool; - return Response.Success($"Set active tool to '{targetTool}'."); + return new SuccessResponse($"Set active tool to '{targetTool}'."); } else { - return Response.Error( + return new ErrorResponse( $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." ); } @@ -152,14 +152,14 @@ namespace MCPForUnity.Editor.Tools { // Potentially try activating a custom tool by name here if needed // This often requires specific editor scripting knowledge for that tool. - return Response.Error( + return new ErrorResponse( $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." ); } } catch (Exception e) { - return Response.Error($"Error setting active tool: {e.Message}"); + return new ErrorResponse($"Error setting active tool: {e.Message}"); } } @@ -168,12 +168,12 @@ namespace MCPForUnity.Editor.Tools private static object AddTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) - return Response.Error("Tag name cannot be empty or whitespace."); + return new ErrorResponse("Tag name cannot be empty or whitespace."); // Check if tag already exists if (System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) { - return Response.Error($"Tag '{tagName}' already exists."); + return new ErrorResponse($"Tag '{tagName}' already exists."); } try @@ -182,25 +182,25 @@ namespace MCPForUnity.Editor.Tools InternalEditorUtility.AddTag(tagName); // Force save assets to ensure the change persists in the TagManager asset AssetDatabase.SaveAssets(); - return Response.Success($"Tag '{tagName}' added successfully."); + return new SuccessResponse($"Tag '{tagName}' added successfully."); } catch (Exception e) { - return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); + return new ErrorResponse($"Failed to add tag '{tagName}': {e.Message}"); } } private static object RemoveTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) - return Response.Error("Tag name cannot be empty or whitespace."); + return new ErrorResponse("Tag name cannot be empty or whitespace."); if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) - return Response.Error("Cannot remove the built-in 'Untagged' tag."); + return new ErrorResponse("Cannot remove the built-in 'Untagged' tag."); // Check if tag exists before attempting removal if (!System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) { - return Response.Error($"Tag '{tagName}' does not exist."); + return new ErrorResponse($"Tag '{tagName}' does not exist."); } try @@ -209,12 +209,12 @@ namespace MCPForUnity.Editor.Tools InternalEditorUtility.RemoveTag(tagName); // Force save assets AssetDatabase.SaveAssets(); - return Response.Success($"Tag '{tagName}' removed successfully."); + return new SuccessResponse($"Tag '{tagName}' removed successfully."); } catch (Exception e) { // Catch potential issues if the tag is somehow in use or removal fails - return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); + return new ErrorResponse($"Failed to remove tag '{tagName}': {e.Message}"); } } @@ -223,16 +223,16 @@ namespace MCPForUnity.Editor.Tools private static object AddLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) - return Response.Error("Layer name cannot be empty or whitespace."); + return new ErrorResponse("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) - return Response.Error("Could not access TagManager asset."); + return new ErrorResponse("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); + return new ErrorResponse("Could not find 'layers' property in TagManager."); // Check if layer name already exists (case-insensitive check recommended) for (int i = 0; i < TotalLayerCount; i++) @@ -243,7 +243,7 @@ namespace MCPForUnity.Editor.Tools && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) ) { - return Response.Error($"Layer '{layerName}' already exists at index {i}."); + return new ErrorResponse($"Layer '{layerName}' already exists at index {i}."); } } @@ -261,7 +261,7 @@ namespace MCPForUnity.Editor.Tools if (firstEmptyUserLayer == -1) { - return Response.Error("No empty User Layer slots available (8-31 are full)."); + return new ErrorResponse("No empty User Layer slots available (8-31 are full)."); } // Assign the name to the found slot @@ -275,29 +275,29 @@ namespace MCPForUnity.Editor.Tools tagManager.ApplyModifiedProperties(); // Save assets to make sure it's written to disk AssetDatabase.SaveAssets(); - return Response.Success( + return new SuccessResponse( $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." ); } catch (Exception e) { - return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); + return new ErrorResponse($"Failed to add layer '{layerName}': {e.Message}"); } } private static object RemoveLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) - return Response.Error("Layer name cannot be empty or whitespace."); + return new ErrorResponse("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) - return Response.Error("Could not access TagManager asset."); + return new ErrorResponse("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); + return new ErrorResponse("Could not find 'layers' property in TagManager."); // Find the layer by name (must be user layer) int layerIndexToRemove = -1; @@ -317,7 +317,7 @@ namespace MCPForUnity.Editor.Tools if (layerIndexToRemove == -1) { - return Response.Error($"User layer '{layerName}' not found."); + return new ErrorResponse($"User layer '{layerName}' not found."); } // Clear the name for that index @@ -331,13 +331,13 @@ namespace MCPForUnity.Editor.Tools tagManager.ApplyModifiedProperties(); // Save assets AssetDatabase.SaveAssets(); - return Response.Success( + return new SuccessResponse( $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." ); } catch (Exception e) { - return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); + return new ErrorResponse($"Failed to remove layer '{layerName}': {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index 1ad4107..ec6b0ef 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -19,7 +19,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles GameObject manipulation within the current scene (CRUD, find, components). /// - [McpForUnityTool("manage_gameobject")] + [McpForUnityTool("manage_gameobject", AutoRegister = false)] public static class ManageGameObject { // Shared JsonSerializer to avoid per-call allocation overhead @@ -43,13 +43,13 @@ namespace MCPForUnity.Editor.Tools { if (@params == null) { - return Response.Error("Parameters cannot be null."); + return new ErrorResponse("Parameters cannot be null."); } string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } // Parameters used by various actions @@ -109,11 +109,11 @@ namespace MCPForUnity.Editor.Tools string compName = @params["componentName"]?.ToString(); JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting if (string.IsNullOrEmpty(compName)) - return Response.Error( + return new ErrorResponse( "Missing 'componentName' for 'set_component_property' on prefab." ); if (compProps == null) - return Response.Error( + return new ErrorResponse( $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." ); @@ -124,7 +124,7 @@ namespace MCPForUnity.Editor.Tools { properties = @params["componentProperties"] as JObject; if (properties == null) - return Response.Error( + return new ErrorResponse( "Missing 'componentProperties' for 'modify' action on prefab." ); } @@ -142,7 +142,7 @@ namespace MCPForUnity.Editor.Tools ) // Added get_components here too { // Explicitly block other modifications on the prefab asset itself via manage_gameobject - return Response.Error( + return new ErrorResponse( $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." ); } @@ -166,7 +166,7 @@ namespace MCPForUnity.Editor.Tools case "get_components": string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string if (getCompTarget == null) - return Response.Error( + return new ErrorResponse( "'target' parameter required for get_components." ); // Pass the includeNonPublicSerialized flag here @@ -174,12 +174,12 @@ namespace MCPForUnity.Editor.Tools case "get_component": string getSingleCompTarget = targetToken?.ToString(); if (getSingleCompTarget == null) - return Response.Error( + return new ErrorResponse( "'target' parameter required for get_component." ); string componentName = @params["componentName"]?.ToString(); if (string.IsNullOrEmpty(componentName)) - return Response.Error( + return new ErrorResponse( "'componentName' parameter required for get_component." ); return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized); @@ -191,13 +191,13 @@ namespace MCPForUnity.Editor.Tools return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); default: - return Response.Error($"Unknown action: '{action}'."); + return new ErrorResponse($"Unknown action: '{action}'."); } } catch (Exception e) { Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); } } @@ -208,7 +208,7 @@ namespace MCPForUnity.Editor.Tools string name = @params["name"]?.ToString(); if (string.IsNullOrEmpty(name)) { - return Response.Error("'name' parameter is required for 'create' action."); + return new ErrorResponse("'name' parameter is required for 'create' action."); } // Get prefab creation parameters @@ -235,7 +235,7 @@ namespace MCPForUnity.Editor.Tools string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); if (guids.Length == 0) { - return Response.Error( + return new ErrorResponse( $"Prefab named '{prefabNameOnly}' not found anywhere in the project." ); } @@ -245,7 +245,7 @@ namespace MCPForUnity.Editor.Tools ", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) ); - return Response.Error( + return new ErrorResponse( $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." ); } @@ -283,7 +283,7 @@ namespace MCPForUnity.Editor.Tools Debug.LogError( $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." ); - return Response.Error( + return new ErrorResponse( $"Failed to instantiate prefab at '{prefabPath}'." ); } @@ -303,7 +303,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error instantiating prefab '{prefabPath}': {e.Message}" ); } @@ -338,7 +338,7 @@ namespace MCPForUnity.Editor.Tools else { UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak - return Response.Error( + return new ErrorResponse( "'name' parameter is required when creating a primitive." ); } @@ -346,13 +346,13 @@ namespace MCPForUnity.Editor.Tools } catch (ArgumentException) { - return Response.Error( + return new ErrorResponse( $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" ); } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Failed to create primitive '{primitiveType}': {e.Message}" ); } @@ -361,7 +361,7 @@ namespace MCPForUnity.Editor.Tools { if (string.IsNullOrEmpty(name)) { - return Response.Error( + return new ErrorResponse( "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." ); } @@ -378,7 +378,7 @@ namespace MCPForUnity.Editor.Tools if (newGo == null) { // Should theoretically not happen if logic above is correct, but safety check. - return Response.Error("Failed to create or instantiate the GameObject."); + return new ErrorResponse("Failed to create or instantiate the GameObject."); } // Record potential changes to the existing prefab instance or the new GO @@ -394,7 +394,7 @@ namespace MCPForUnity.Editor.Tools if (parentGo == null) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object - return Response.Error($"Parent specified ('{parentToken}') but not found."); + return new ErrorResponse($"Parent specified ('{parentToken}') but not found."); } newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true } @@ -438,7 +438,7 @@ namespace MCPForUnity.Editor.Tools catch (Exception innerEx) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error( + return new ErrorResponse( $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." ); } @@ -446,7 +446,7 @@ namespace MCPForUnity.Editor.Tools else { UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error( + return new ErrorResponse( $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." ); } @@ -516,7 +516,7 @@ namespace MCPForUnity.Editor.Tools { // Clean up the created object before returning error UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error( + return new ErrorResponse( "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." ); } @@ -555,7 +555,7 @@ namespace MCPForUnity.Editor.Tools { // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error( + return new ErrorResponse( $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." ); } @@ -569,7 +569,7 @@ namespace MCPForUnity.Editor.Tools { // Clean up the instance if prefab saving fails UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt - return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}"); } } @@ -602,8 +602,8 @@ namespace MCPForUnity.Editor.Tools } // Use the new serializer helper - //return Response.Success(successMessage, GetGameObjectData(finalInstance)); - return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); + //return new SuccessResponse(successMessage, GetGameObjectData(finalInstance)); + return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); } private static object ModifyGameObject( @@ -615,7 +615,7 @@ namespace MCPForUnity.Editor.Tools GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -651,11 +651,11 @@ namespace MCPForUnity.Editor.Tools ) ) { - return Response.Error($"New parent ('{parentToken}') not found."); + return new ErrorResponse($"New parent ('{parentToken}') not found."); } if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) { - return Response.Error( + return new ErrorResponse( $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." ); } @@ -715,7 +715,7 @@ namespace MCPForUnity.Editor.Tools Debug.LogError( $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" ); - return Response.Error( + return new ErrorResponse( $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." ); } @@ -723,7 +723,7 @@ namespace MCPForUnity.Editor.Tools else { // If the exception was for a different reason, return the original error - return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); + return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}."); } } } @@ -735,7 +735,7 @@ namespace MCPForUnity.Editor.Tools int layerId = LayerMask.NameToLayer(layerName); if (layerId == -1 && layerName != "Default") { - return Response.Error( + return new ErrorResponse( $"Invalid layer specified: '{layerName}'. Use a valid layer name." ); } @@ -867,7 +867,7 @@ namespace MCPForUnity.Editor.Tools catch { } } - return Response.Error( + return new ErrorResponse( $"One or more component property operations failed on '{targetGo.name}'.", new { componentErrors = componentErrors, errors = aggregatedErrors } ); @@ -876,11 +876,11 @@ namespace MCPForUnity.Editor.Tools if (!modified) { // Use the new serializer helper - // return Response.Success( + // return new SuccessResponse( // $"No modifications applied to GameObject '{targetGo.name}'.", // GetGameObjectData(targetGo)); - return Response.Success( + return new SuccessResponse( $"No modifications applied to GameObject '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); @@ -888,11 +888,11 @@ namespace MCPForUnity.Editor.Tools EditorUtility.SetDirty(targetGo); // Mark scene as dirty // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"GameObject '{targetGo.name}' modified successfully.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); - // return Response.Success( + // return new SuccessResponse( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); @@ -905,7 +905,7 @@ namespace MCPForUnity.Editor.Tools if (targets.Count == 0) { - return Response.Error( + return new ErrorResponse( $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -929,12 +929,12 @@ namespace MCPForUnity.Editor.Tools targets.Count == 1 ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." : $"{deletedObjects.Count} GameObjects deleted successfully."; - return Response.Success(message, deletedObjects); + return new SuccessResponse(message, deletedObjects); } else { // Should not happen if targets.Count > 0 initially, but defensive check - return Response.Error("Failed to delete target GameObject(s)."); + return new ErrorResponse("Failed to delete target GameObject(s)."); } } @@ -954,13 +954,13 @@ namespace MCPForUnity.Editor.Tools if (foundObjects.Count == 0) { - return Response.Success("No matching GameObjects found.", new List()); + return new SuccessResponse("No matching GameObjects found.", new List()); } // Use the new serializer helper //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); - return Response.Success($"Found {results.Count} GameObject(s).", results); + return new SuccessResponse($"Found {results.Count} GameObject(s).", results); } private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) @@ -968,7 +968,7 @@ namespace MCPForUnity.Editor.Tools GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1023,14 +1023,14 @@ namespace MCPForUnity.Editor.Tools componentsToIterate.Clear(); componentsToIterate = null; - return Response.Success( + return new SuccessResponse( $"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData // List was built in original order ); } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error getting components from '{targetGo.name}': {e.Message}" ); } @@ -1041,7 +1041,7 @@ namespace MCPForUnity.Editor.Tools GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1073,7 +1073,7 @@ namespace MCPForUnity.Editor.Tools if (targetComponent == null) { - return Response.Error( + return new ErrorResponse( $"Component '{componentName}' not found on GameObject '{targetGo.name}'." ); } @@ -1082,19 +1082,19 @@ namespace MCPForUnity.Editor.Tools if (componentData == null) { - return Response.Error( + return new ErrorResponse( $"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'." ); } - return Response.Success( + return new SuccessResponse( $"Retrieved component '{componentName}' from '{targetGo.name}'.", componentData ); } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}" ); } @@ -1109,7 +1109,7 @@ namespace MCPForUnity.Editor.Tools GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1140,7 +1140,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(typeName)) { - return Response.Error( + return new ErrorResponse( "Component type name ('componentName' or first element in 'componentsToAdd') is required." ); } @@ -1151,7 +1151,7 @@ namespace MCPForUnity.Editor.Tools EditorUtility.SetDirty(targetGo); // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"Component '{typeName}' added to '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // Return updated GO data @@ -1166,7 +1166,7 @@ namespace MCPForUnity.Editor.Tools GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1187,7 +1187,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(typeName)) { - return Response.Error( + return new ErrorResponse( "Component type name ('componentName' or first element in 'componentsToRemove') is required." ); } @@ -1198,7 +1198,7 @@ namespace MCPForUnity.Editor.Tools EditorUtility.SetDirty(targetGo); // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"Component '{typeName}' removed from '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); @@ -1213,7 +1213,7 @@ namespace MCPForUnity.Editor.Tools GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1231,12 +1231,12 @@ namespace MCPForUnity.Editor.Tools } else { - return Response.Error("'componentName' parameter is required."); + return new ErrorResponse("'componentName' parameter is required."); } if (propertiesToSet == null || !propertiesToSet.HasValues) { - return Response.Error( + return new ErrorResponse( "'componentProperties' dictionary for the specified component is required and cannot be empty." ); } @@ -1247,7 +1247,7 @@ namespace MCPForUnity.Editor.Tools EditorUtility.SetDirty(targetGo); // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"Properties set for component '{compName}' on '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); @@ -1499,19 +1499,19 @@ namespace MCPForUnity.Editor.Tools Type componentType = FindType(typeName); if (componentType == null) { - return Response.Error( + return new ErrorResponse( $"Component type '{typeName}' not found or is not a valid Component." ); } if (!typeof(Component).IsAssignableFrom(componentType)) { - return Response.Error($"Type '{typeName}' is not a Component."); + return new ErrorResponse($"Type '{typeName}' is not a Component."); } // Prevent adding Transform again if (componentType == typeof(Transform)) { - return Response.Error("Cannot add another Transform component."); + return new ErrorResponse("Cannot add another Transform component."); } // Check for 2D/3D physics component conflicts @@ -1530,7 +1530,7 @@ namespace MCPForUnity.Editor.Tools || targetGo.GetComponent() != null ) { - return Response.Error( + return new ErrorResponse( $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." ); } @@ -1543,7 +1543,7 @@ namespace MCPForUnity.Editor.Tools || targetGo.GetComponent() != null ) { - return Response.Error( + return new ErrorResponse( $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." ); } @@ -1555,7 +1555,7 @@ namespace MCPForUnity.Editor.Tools Component newComponent = Undo.AddComponent(targetGo, componentType); if (newComponent == null) { - return Response.Error( + return new ErrorResponse( $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." ); } @@ -1588,7 +1588,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" ); } @@ -1603,19 +1603,19 @@ namespace MCPForUnity.Editor.Tools Type componentType = FindType(typeName); if (componentType == null) { - return Response.Error($"Component type '{typeName}' not found for removal."); + return new ErrorResponse($"Component type '{typeName}' not found for removal."); } // Prevent removing essential components if (componentType == typeof(Transform)) { - return Response.Error("Cannot remove the Transform component."); + return new ErrorResponse("Cannot remove the Transform component."); } Component componentToRemove = targetGo.GetComponent(componentType); if (componentToRemove == null) { - return Response.Error( + return new ErrorResponse( $"Component '{typeName}' not found on '{targetGo.name}' to remove." ); } @@ -1628,7 +1628,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" ); } @@ -1659,7 +1659,7 @@ namespace MCPForUnity.Editor.Tools } if (targetComponent == null) { - return Response.Error( + return new ErrorResponse( $"Component '{compName}' not found on '{targetGo.name}' to set properties." ); } @@ -1697,7 +1697,7 @@ namespace MCPForUnity.Editor.Tools EditorUtility.SetDirty(targetComponent); return failures.Count == 0 ? null - : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); + : new ErrorResponse($"One or more properties failed on '{compName}'.", new { errors = failures }); } /// diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 6a310d0..d8ff578 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles scene management operations like loading, saving, creating, and querying hierarchy. /// - [McpForUnityTool("manage_scene")] + [McpForUnityTool("manage_scene", AutoRegister = false)] public static class ManageScene { private sealed class SceneCommand @@ -78,7 +78,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; @@ -101,7 +101,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } @@ -113,7 +113,7 @@ namespace MCPForUnity.Editor.Tools { case "create": if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) - return Response.Error( + return new ErrorResponse( "'name' and 'path' parameters are required for 'create' action." ); return CreateScene(fullPath, relativePath); @@ -124,7 +124,7 @@ namespace MCPForUnity.Editor.Tools else if (buildIndex.HasValue) return LoadScene(buildIndex.Value); else - return Response.Error( + return new ErrorResponse( "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." ); case "save": @@ -144,7 +144,7 @@ namespace MCPForUnity.Editor.Tools return GetBuildSettingsScenes(); // Add cases for modifying build settings, additive loading, unloading etc. default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." ); } @@ -154,7 +154,7 @@ namespace MCPForUnity.Editor.Tools { if (File.Exists(fullPath)) { - return Response.Error($"Scene already exists at '{relativePath}'."); + return new ErrorResponse($"Scene already exists at '{relativePath}'."); } try @@ -170,7 +170,7 @@ namespace MCPForUnity.Editor.Tools if (saved) { AssetDatabase.Refresh(); // Ensure Unity sees the new scene file - return Response.Success( + return new SuccessResponse( $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", new { path = relativePath } ); @@ -179,12 +179,12 @@ namespace MCPForUnity.Editor.Tools { // If SaveScene fails, it might leave an untitled scene open. // Optionally try to close it, but be cautious. - return Response.Error($"Failed to save new scene to '{relativePath}'."); + return new ErrorResponse($"Failed to save new scene to '{relativePath}'."); } } catch (Exception e) { - return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); + return new ErrorResponse($"Error creating scene '{relativePath}': {e.Message}"); } } @@ -202,24 +202,24 @@ namespace MCPForUnity.Editor.Tools ) ) { - return Response.Error($"Scene file not found at '{relativePath}'."); + return new ErrorResponse($"Scene file not found at '{relativePath}'."); } // Check for unsaved changes in the current scene if (EditorSceneManager.GetActiveScene().isDirty) { // Optionally prompt the user or save automatically before loading - return Response.Error( + return new ErrorResponse( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); - // if (!saveOK) return Response.Error("Load cancelled by user."); + // if (!saveOK) return new ErrorResponse("Load cancelled by user."); } try { EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); - return Response.Success( + return new SuccessResponse( $"Scene '{relativePath}' loaded successfully.", new { @@ -230,7 +230,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); + return new ErrorResponse($"Error loading scene '{relativePath}': {e.Message}"); } } @@ -238,7 +238,7 @@ namespace MCPForUnity.Editor.Tools { if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) { - return Response.Error( + return new ErrorResponse( $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." ); } @@ -246,7 +246,7 @@ namespace MCPForUnity.Editor.Tools // Check for unsaved changes if (EditorSceneManager.GetActiveScene().isDirty) { - return Response.Error( + return new ErrorResponse( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); } @@ -255,7 +255,7 @@ namespace MCPForUnity.Editor.Tools { string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); - return Response.Success( + return new SuccessResponse( $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", new { @@ -267,7 +267,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error loading scene with build index {buildIndex}: {e.Message}" ); } @@ -280,7 +280,7 @@ namespace MCPForUnity.Editor.Tools Scene currentScene = EditorSceneManager.GetActiveScene(); if (!currentScene.IsValid()) { - return Response.Error("No valid scene is currently active to save."); + return new ErrorResponse("No valid scene is currently active to save."); } bool saved; @@ -303,7 +303,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(currentScene.path)) { // Scene is untitled, needs a path - return Response.Error( + return new ErrorResponse( "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." ); } @@ -313,19 +313,19 @@ namespace MCPForUnity.Editor.Tools if (saved) { AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name } ); } else { - return Response.Error($"Failed to save scene '{currentScene.name}'."); + return new ErrorResponse($"Failed to save scene '{currentScene.name}'."); } } catch (Exception e) { - return Response.Error($"Error saving scene: {e.Message}"); + return new ErrorResponse($"Error saving scene: {e.Message}"); } } @@ -338,7 +338,7 @@ namespace MCPForUnity.Editor.Tools try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid()) { - return Response.Error("No active scene found."); + return new ErrorResponse("No active scene found."); } var sceneInfo = new @@ -351,12 +351,12 @@ namespace MCPForUnity.Editor.Tools rootCount = activeScene.rootCount, }; - return Response.Success("Retrieved active scene information.", sceneInfo); + return new SuccessResponse("Retrieved active scene information.", sceneInfo); } catch (Exception e) { try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } - return Response.Error($"Error getting active scene info: {e.Message}"); + return new ErrorResponse($"Error getting active scene info: {e.Message}"); } } @@ -378,11 +378,11 @@ namespace MCPForUnity.Editor.Tools } ); } - return Response.Success("Retrieved scenes from Build Settings.", scenes); + return new SuccessResponse("Retrieved scenes from Build Settings.", scenes); } catch (Exception e) { - return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); + return new ErrorResponse($"Error getting scenes from Build Settings: {e.Message}"); } } @@ -395,7 +395,7 @@ namespace MCPForUnity.Editor.Tools try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid() || !activeScene.isLoaded) { - return Response.Error( + return new ErrorResponse( "No valid and loaded scene is active to get hierarchy from." ); } @@ -405,7 +405,7 @@ namespace MCPForUnity.Editor.Tools try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); - var resp = Response.Success( + var resp = new SuccessResponse( $"Retrieved hierarchy for scene '{activeScene.name}'.", hierarchy ); @@ -415,7 +415,7 @@ namespace MCPForUnity.Editor.Tools catch (Exception e) { try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } - return Response.Error($"Error getting scene hierarchy: {e.Message}"); + return new ErrorResponse($"Error getting scene hierarchy: {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index b5cbbb1..5e268ca 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using System.Threading; using System.Security.Cryptography; @@ -49,7 +50,7 @@ namespace MCPForUnity.Editor.Tools /// Note: Without Roslyn, the system falls back to basic structural validation. /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. /// - [McpForUnityTool("manage_script")] + [McpForUnityTool("manage_script", AutoRegister = false)] public static class ManageScript { /// @@ -114,7 +115,7 @@ namespace MCPForUnity.Editor.Tools // Handle null parameters if (@params == null) { - return Response.Error("invalid_params", "Parameters cannot be null."); + return new ErrorResponse("invalid_params", "Parameters cannot be null."); } // Extract parameters @@ -133,7 +134,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error($"Failed to decode script contents: {e.Message}"); + return new ErrorResponse($"Failed to decode script contents: {e.Message}"); } } else @@ -147,16 +148,16 @@ namespace MCPForUnity.Editor.Tools // Validate required parameters if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } if (string.IsNullOrEmpty(name)) { - return Response.Error("Name parameter is required."); + return new ErrorResponse("Name parameter is required."); } // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) { - return Response.Error( + return new ErrorResponse( $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." ); } @@ -164,7 +165,7 @@ namespace MCPForUnity.Editor.Tools // Resolve and harden target directory under Assets/ if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) { - return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); + return new ErrorResponse($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); } // Construct file paths @@ -181,7 +182,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } @@ -229,7 +230,7 @@ namespace MCPForUnity.Editor.Tools }; string fileText; try { fileText = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); var diags = (diagsRaw ?? Array.Empty()).Select(s => @@ -247,8 +248,8 @@ namespace MCPForUnity.Editor.Tools }).ToArray(); var result = new { diagnostics = diags }; - return ok ? Response.Success("Validation completed.", result) - : Response.Error("Validation failed.", result); + return ok ? new SuccessResponse("Validation completed.", result) + : new ErrorResponse("Validation failed.", result); } case "edit": Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); @@ -260,7 +261,7 @@ namespace MCPForUnity.Editor.Tools try { if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); string text = File.ReadAllText(fullPath); string sha = ComputeSha256(text); @@ -276,15 +277,15 @@ namespace MCPForUnity.Editor.Tools lengthBytes, lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty }; - return Response.Success($"SHA computed for '{relativePath}'.", data); + return new SuccessResponse($"SHA computed for '{relativePath}'.", data); } catch (Exception ex) { - return Response.Error($"Failed to compute SHA: {ex.Message}"); + return new ErrorResponse($"Failed to compute SHA: {ex.Message}"); } } default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." ); } @@ -320,7 +321,7 @@ namespace MCPForUnity.Editor.Tools // Check if script already exists if (File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Script already exists at '{relativePath}'. Use 'update' action to modify." ); } @@ -336,7 +337,7 @@ namespace MCPForUnity.Editor.Tools bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); } else if (validationErrors != null && validationErrors.Length > 0) { @@ -361,7 +362,7 @@ namespace MCPForUnity.Editor.Tools } var uri = $"unity://path/{relativePath}"; - var ok = Response.Success( + var ok = new SuccessResponse( $"Script '{name}.cs' created successfully at '{relativePath}'.", new { uri, scheduledRefresh = false } ); @@ -372,7 +373,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to create script '{relativePath}': {e.Message}"); } } @@ -380,7 +381,7 @@ namespace MCPForUnity.Editor.Tools { if (!File.Exists(fullPath)) { - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); } try @@ -400,14 +401,14 @@ namespace MCPForUnity.Editor.Tools contentsEncoded = isLarge, }; - return Response.Success( + return new SuccessResponse( $"Script '{Path.GetFileName(relativePath)}' read successfully.", responseData ); } catch (Exception e) { - return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to read script '{relativePath}': {e.Message}"); } } @@ -420,13 +421,13 @@ namespace MCPForUnity.Editor.Tools { if (!File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Script not found at '{relativePath}'. Use 'create' action to add a new script." ); } if (string.IsNullOrEmpty(contents)) { - return Response.Error("Content is required for the 'update' action."); + return new ErrorResponse("Content is required for the 'update' action."); } // Validate syntax with detailed error reporting using GUI setting @@ -434,7 +435,7 @@ namespace MCPForUnity.Editor.Tools bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); } else if (validationErrors != null && validationErrors.Length > 0) { @@ -470,7 +471,7 @@ namespace MCPForUnity.Editor.Tools // Prepare success response BEFORE any operation that can trigger a domain reload var uri = $"unity://path/{relativePath}"; - var ok = Response.Success( + var ok = new SuccessResponse( $"Script '{name}.cs' updated successfully at '{relativePath}'.", new { uri, path = relativePath, scheduledRefresh = true } ); @@ -482,7 +483,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to update script '{relativePath}': {e.Message}"); } } @@ -501,7 +502,7 @@ namespace MCPForUnity.Editor.Tools string validateMode = null) { if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); // Refuse edits if the target or any ancestor is a symlink try { @@ -509,7 +510,7 @@ namespace MCPForUnity.Editor.Tools while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) { if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) - return Response.Error("Refusing to edit a symlinked script path."); + return new ErrorResponse("Refusing to edit a symlinked script path."); di = di.Parent; } } @@ -518,18 +519,18 @@ namespace MCPForUnity.Editor.Tools // If checking attributes fails, proceed without the symlink guard } if (edits == null || edits.Count == 0) - return Response.Error("No edits provided."); + return new ErrorResponse("No edits provided."); string original; try { original = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } // Require precondition to avoid drift on large files string currentSha = ComputeSha256(original); if (string.IsNullOrEmpty(preconditionSha256)) - return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + return new ErrorResponse("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) - return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + return new ErrorResponse("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); // Convert edits to absolute index ranges var spans = new List<(int start, int end, string text)>(); @@ -545,9 +546,9 @@ namespace MCPForUnity.Editor.Tools string newText = e.Value("newText") ?? string.Empty; if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) - return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + return new ErrorResponse($"apply_text_edits: start out of range (line {sl}, col {sc})"); if (!TryIndexFromLineCol(original, el, ec, out int eidx)) - return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + return new ErrorResponse($"apply_text_edits: end out of range (line {el}, col {ec})"); if (eidx < sidx) (sidx, eidx) = (eidx, sidx); spans.Add((sidx, eidx, newText)); @@ -558,7 +559,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"Invalid edit payload: {ex.Message}"); + return new ErrorResponse($"Invalid edit payload: {ex.Message}"); } } @@ -579,7 +580,7 @@ namespace MCPForUnity.Editor.Tools { if (sp.start < headerBoundary) { - return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + return new ErrorResponse("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); } } @@ -650,7 +651,7 @@ namespace MCPForUnity.Editor.Tools if (totalBytes > MaxEditPayloadBytes) { - return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + return new ErrorResponse("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); } // Ensure non-overlap and apply from back to front @@ -660,7 +661,7 @@ namespace MCPForUnity.Editor.Tools if (spans[i].end > spans[i - 1].start) { var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; - return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + return new ErrorResponse("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } @@ -678,7 +679,7 @@ namespace MCPForUnity.Editor.Tools int endPos = sp.start + newLength; if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) { - return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); + return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); } } working = next; @@ -688,7 +689,7 @@ namespace MCPForUnity.Editor.Tools if (string.Equals(working, original, StringComparison.Ordinal)) { string noChangeSha = ComputeSha256(original); - return Response.Success( + return new SuccessResponse( $"No-op: contents unchanged for '{relativePath}'.", new { @@ -708,7 +709,7 @@ namespace MCPForUnity.Editor.Tools int startLine = Math.Max(1, line - 5); int endLine = line + 5; string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; - return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); + return new ErrorResponse(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); } #if USE_ROSLYN @@ -727,7 +728,7 @@ namespace MCPForUnity.Editor.Tools int firstLine = diagnostics[0].line; int startLineRos = Math.Max(1, firstLine - 5); int endLineRos = firstLine + 5; - return Response.Error("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); + return new ErrorResponse("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); } // Optional formatting @@ -789,7 +790,7 @@ namespace MCPForUnity.Editor.Tools ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } - return Response.Success( + return new SuccessResponse( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { @@ -803,7 +804,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"Failed to write edits: {ex.Message}"); + return new ErrorResponse($"Failed to write edits: {ex.Message}"); } } @@ -957,7 +958,7 @@ namespace MCPForUnity.Editor.Tools { if (!File.Exists(fullPath)) { - return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); + return new ErrorResponse($"Script not found at '{relativePath}'. Cannot delete."); } try @@ -967,7 +968,7 @@ namespace MCPForUnity.Editor.Tools if (deleted) { AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", new { deleted = true } ); @@ -975,14 +976,14 @@ namespace MCPForUnity.Editor.Tools else { // Fallback or error if MoveAssetToTrash fails - return Response.Error( + return new ErrorResponse( $"Failed to move script '{relativePath}' to trash. It might be locked or in use." ); } } catch (Exception e) { - return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Error deleting script '{relativePath}': {e.Message}"); } } @@ -999,24 +1000,24 @@ namespace MCPForUnity.Editor.Tools JObject options) { if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); // Refuse edits if the target is a symlink try { var attrs = File.GetAttributes(fullPath); if ((attrs & FileAttributes.ReparsePoint) != 0) - return Response.Error("Refusing to edit a symlinked script path."); + return new ErrorResponse("Refusing to edit a symlinked script path."); } catch { // ignore failures checking attributes and proceed } if (edits == null || edits.Count == 0) - return Response.Error("No edits provided."); + return new ErrorResponse("No edits provided."); string original; try { original = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } string working = original; @@ -1044,15 +1045,15 @@ namespace MCPForUnity.Editor.Tools string replacement = ExtractReplacement(op); if (string.IsNullOrWhiteSpace(className)) - return Response.Error("replace_class requires 'className'."); + return new ErrorResponse("replace_class requires 'className'."); if (replacement == null) - return Response.Error("replace_class requires 'replacement' (inline or base64)."); + return new ErrorResponse("replace_class requires 'replacement' (inline or base64)."); if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) - return Response.Error($"replace_class failed: {why}"); + return new ErrorResponse($"replace_class failed: {why}"); if (!ValidateClassSnippet(replacement, className, out var vErr)) - return Response.Error($"Replacement snippet invalid: {vErr}"); + return new ErrorResponse($"Replacement snippet invalid: {vErr}"); if (applySequentially) { @@ -1071,10 +1072,10 @@ namespace MCPForUnity.Editor.Tools string className = op.Value("className"); string ns = op.Value("namespace"); if (string.IsNullOrWhiteSpace(className)) - return Response.Error("delete_class requires 'className'."); + return new ErrorResponse("delete_class requires 'className'."); if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) - return Response.Error($"delete_class failed: {why}"); + return new ErrorResponse($"delete_class failed: {why}"); if (applySequentially) { @@ -1098,12 +1099,12 @@ namespace MCPForUnity.Editor.Tools string parametersSignature = op.Value("parametersSignature"); string attributesContains = op.Value("attributesContains"); - if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); - if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); - if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return new ErrorResponse("replace_method requires 'methodName'."); + if (replacement == null) return new ErrorResponse("replace_method requires 'replacement' (inline or base64)."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) - return Response.Error($"replace_method failed to locate class: {whyClass}"); + return new ErrorResponse($"replace_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) { @@ -1112,7 +1113,7 @@ namespace MCPForUnity.Editor.Tools string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; - return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + return new ErrorResponse($"replace_method failed: {whyMethod}.{hint}"); } if (applySequentially) @@ -1136,11 +1137,11 @@ namespace MCPForUnity.Editor.Tools string parametersSignature = op.Value("parametersSignature"); string attributesContains = op.Value("attributesContains"); - if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); - if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return new ErrorResponse("delete_method requires 'methodName'."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) - return Response.Error($"delete_method failed to locate class: {whyClass}"); + return new ErrorResponse($"delete_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) { @@ -1149,7 +1150,7 @@ namespace MCPForUnity.Editor.Tools string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; - return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + return new ErrorResponse($"delete_method failed: {whyMethod}.{hint}"); } if (applySequentially) @@ -1176,19 +1177,19 @@ namespace MCPForUnity.Editor.Tools string snippet = ExtractReplacement(op); // Harden: refuse empty replacement for inserts if (snippet == null || snippet.Trim().Length == 0) - return Response.Error("insert_method requires a non-empty 'replacement' text."); + return new ErrorResponse("insert_method requires a non-empty 'replacement' text."); - if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); - if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("insert_method requires 'className'."); + if (snippet == null) return new ErrorResponse("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) - return Response.Error($"insert_method failed to locate class: {whyClass}"); + return new ErrorResponse($"insert_method failed to locate class: {whyClass}"); if (position == "after") { - if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (string.IsNullOrEmpty(afterMethodName)) return new ErrorResponse("insert_method with position='after' requires 'afterMethodName'."); if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) - return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + return new ErrorResponse($"insert_method(after) failed to locate anchor method: {whyAfter}"); int insAt = aStart + aLen; string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); if (applySequentially) @@ -1202,7 +1203,7 @@ namespace MCPForUnity.Editor.Tools } } else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) - return Response.Error($"insert_method failed: {whyIns}"); + return new ErrorResponse($"insert_method failed: {whyIns}"); else { string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); @@ -1224,14 +1225,14 @@ namespace MCPForUnity.Editor.Tools string anchor = op.Value("anchor"); string position = (op.Value("position") ?? "before").ToLowerInvariant(); string text = op.Value("text") ?? ExtractReplacement(op); - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); - if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return new ErrorResponse("anchor_insert requires non-empty 'text'."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); - if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); + if (!m.Success) return new ErrorResponse($"anchor_insert: anchor not found: {anchor}"); int insAt = position == "after" ? m.Index + m.Length : m.Index; string norm = NormalizeNewlines(text); if (!norm.EndsWith("\n")) @@ -1261,7 +1262,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"anchor_insert failed: {ex.Message}"); + return new ErrorResponse($"anchor_insert failed: {ex.Message}"); } break; } @@ -1269,12 +1270,12 @@ namespace MCPForUnity.Editor.Tools case "anchor_delete": { string anchor = op.Value("anchor"); - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_delete requires 'anchor' (regex)."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); - if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); + if (!m.Success) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}"); int delAt = m.Index; int delLen = m.Length; if (applySequentially) @@ -1289,7 +1290,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"anchor_delete failed: {ex.Message}"); + return new ErrorResponse($"anchor_delete failed: {ex.Message}"); } break; } @@ -1298,12 +1299,12 @@ namespace MCPForUnity.Editor.Tools { string anchor = op.Value("anchor"); string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_replace requires 'anchor' (regex)."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); - if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); + if (!m.Success) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}"); int at = m.Index; int len = m.Length; string norm = NormalizeNewlines(replacement); @@ -1319,13 +1320,13 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"anchor_replace failed: {ex.Message}"); + return new ErrorResponse($"anchor_replace failed: {ex.Message}"); } break; } default: - return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); + return new ErrorResponse($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); } } @@ -1339,10 +1340,10 @@ namespace MCPForUnity.Editor.Tools if (ordered[i].start + ordered[i].length > ordered[i - 1].start) { var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; - return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + return new ErrorResponse("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } - return Response.Error("overlap", new { status = "overlap" }); + return new ErrorResponse("overlap", new { status = "overlap" }); } foreach (var r in replacements.OrderByDescending(r => r.start)) @@ -1352,13 +1353,13 @@ namespace MCPForUnity.Editor.Tools // Guard against structural imbalance before validation if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) - return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); + return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); // No-op guard for structured edits: if text unchanged, return explicit no-op if (string.Equals(working, original, StringComparison.Ordinal)) { var sameSha = ComputeSha256(original); - return Response.Success( + return new SuccessResponse( $"No-op: contents unchanged for '{relativePath}'.", new { @@ -1391,7 +1392,7 @@ namespace MCPForUnity.Editor.Tools } catch { /* ignore option parsing issues */ } if (!ValidateScriptSyntax(working, level, out var errors)) - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); else if (errors != null && errors.Length > 0) Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); @@ -1424,7 +1425,7 @@ namespace MCPForUnity.Editor.Tools } var newSha = ComputeSha256(working); - var ok = Response.Success( + var ok = new SuccessResponse( $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", new { @@ -1449,7 +1450,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"Edit failed: {ex.Message}"); + return new ErrorResponse($"Edit failed: {ex.Message}"); } } @@ -1933,7 +1934,7 @@ namespace MCPForUnity.Editor.Tools /// private static ValidationLevel GetValidationLevelFromGUI() { - int savedLevel = EditorPrefs.GetInt("MCPForUnity.ValidationLevel", (int)ValidationLevel.Standard); + int savedLevel = EditorPrefs.GetInt(EditorPrefKeys.ValidationLevel, (int)ValidationLevel.Standard); return (ValidationLevel)Mathf.Clamp(savedLevel, 0, 3); } @@ -2500,7 +2501,7 @@ namespace MCPForUnity.Editor.Tools // if (string.IsNullOrEmpty(contents)) // { - // return Response.Error("Contents parameter is required for validation."); + // return new ErrorResponse("Contents parameter is required for validation."); // } // // Parse validation level @@ -2512,7 +2513,7 @@ namespace MCPForUnity.Editor.Tools // case "comprehensive": level = ValidationLevel.Comprehensive; break; // case "strict": level = ValidationLevel.Strict; break; // default: - // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); + // return new ErrorResponse($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); // } // // Perform validation @@ -2536,11 +2537,11 @@ namespace MCPForUnity.Editor.Tools // if (isValid) // { - // return Response.Success("Script validation completed successfully.", result); + // return new SuccessResponse("Script validation completed successfully.", result); // } // else // { - // return Response.Error("Script validation failed.", result); + // return new ErrorResponse("Script validation failed.", result); // } // } } diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs index 2d7f4d0..8b59fb2 100644 --- a/MCPForUnity/Editor/Tools/ManageShader.cs +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles CRUD operations for shader files within the Unity project. /// - [McpForUnityTool("manage_shader")] + [McpForUnityTool("manage_shader", AutoRegister = false)] public static class ManageShader { /// @@ -36,7 +36,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error($"Failed to decode shader contents: {e.Message}"); + return new ErrorResponse($"Failed to decode shader contents: {e.Message}"); } } else @@ -47,16 +47,16 @@ namespace MCPForUnity.Editor.Tools // Validate required parameters if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } if (string.IsNullOrEmpty(name)) { - return Response.Error("Name parameter is required."); + return new ErrorResponse("Name parameter is required."); } // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) { - return Response.Error( + return new ErrorResponse( $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." ); } @@ -99,7 +99,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } @@ -117,7 +117,7 @@ namespace MCPForUnity.Editor.Tools case "delete": return DeleteShader(fullPath, relativePath); default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." ); } @@ -151,7 +151,7 @@ namespace MCPForUnity.Editor.Tools // Check if shader already exists if (File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Shader already exists at '{relativePath}'. Use 'update' action to modify." ); } @@ -159,7 +159,7 @@ namespace MCPForUnity.Editor.Tools // Add validation for shader name conflicts in Unity if (Shader.Find(name) != null) { - return Response.Error( + return new ErrorResponse( $"A shader with name '{name}' already exists in the project. Choose a different name." ); } @@ -175,14 +175,14 @@ namespace MCPForUnity.Editor.Tools File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader - return Response.Success( + return new SuccessResponse( $"Shader '{name}.shader' created successfully at '{relativePath}'.", new { path = relativePath } ); } catch (Exception e) { - return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to create shader '{relativePath}': {e.Message}"); } } @@ -190,7 +190,7 @@ namespace MCPForUnity.Editor.Tools { if (!File.Exists(fullPath)) { - return Response.Error($"Shader not found at '{relativePath}'."); + return new ErrorResponse($"Shader not found at '{relativePath}'."); } try @@ -209,14 +209,14 @@ namespace MCPForUnity.Editor.Tools contentsEncoded = isLarge, }; - return Response.Success( + return new SuccessResponse( $"Shader '{Path.GetFileName(relativePath)}' read successfully.", responseData ); } catch (Exception e) { - return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to read shader '{relativePath}': {e.Message}"); } } @@ -229,13 +229,13 @@ namespace MCPForUnity.Editor.Tools { if (!File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." ); } if (string.IsNullOrEmpty(contents)) { - return Response.Error("Content is required for the 'update' action."); + return new ErrorResponse("Content is required for the 'update' action."); } try @@ -243,14 +243,14 @@ namespace MCPForUnity.Editor.Tools File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", new { path = relativePath } ); } catch (Exception e) { - return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to update shader '{relativePath}': {e.Message}"); } } @@ -258,7 +258,7 @@ namespace MCPForUnity.Editor.Tools { if (!File.Exists(fullPath)) { - return Response.Error($"Shader not found at '{relativePath}'."); + return new ErrorResponse($"Shader not found at '{relativePath}'."); } try @@ -267,7 +267,7 @@ namespace MCPForUnity.Editor.Tools bool success = AssetDatabase.DeleteAsset(relativePath); if (!success) { - return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); + return new ErrorResponse($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); } // If the file still exists (rare case), try direct deletion @@ -276,11 +276,11 @@ namespace MCPForUnity.Editor.Tools File.Delete(fullPath); } - return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); + return new SuccessResponse($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); } catch (Exception e) { - return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to delete shader '{relativePath}': {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs index bb4e043..e4db3a4 100644 --- a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -3,17 +3,55 @@ using System; namespace MCPForUnity.Editor.Tools { /// - /// Marks a class as an MCP tool handler for auto-discovery. - /// The class must have a public static HandleCommand(JObject) method. + /// Marks a class as an MCP tool handler /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class McpForUnityToolAttribute : Attribute { + /// + /// Tool name (if null, derived from class name) + /// + public string Name { get; set; } + + /// + /// Tool description for LLM + /// + public string Description { get; set; } + + /// + /// Whether this tool returns structured output + /// + public bool StructuredOutput { get; set; } = true; + + /// + /// Controls whether this tool is automatically registered with FastMCP. + /// Defaults to true so most tools opt-in automatically. Set to false + /// for legacy/built-in tools that already exist server-side. + /// + public bool AutoRegister { get; set; } = true; + + /// + /// Enables the polling middleware for long-running tools. When true, Unity + /// should return a PendingResponse and the Python side will poll using + /// until completion. + /// + public bool RequiresPolling { get; set; } = false; + + /// + /// The action name to use when polling for status. Defaults to "status". + /// + public string PollAction { get; set; } = "status"; + /// /// The command name used to route requests to this tool. /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// Kept for backward compatibility. /// - public string CommandName { get; } + public string CommandName + { + get => Name; + set => Name = value; + } /// /// Create an MCP tool attribute with auto-generated command name. @@ -22,16 +60,48 @@ namespace MCPForUnity.Editor.Tools /// public McpForUnityToolAttribute() { - CommandName = null; // Will be auto-generated + Name = null; // Will be auto-generated } /// /// Create an MCP tool attribute with explicit command name. /// - /// The command name (e.g., "manage_asset") - public McpForUnityToolAttribute(string commandName) + /// The command name (e.g., "manage_asset") + public McpForUnityToolAttribute(string name = null) { - CommandName = commandName; + Name = name; + } + } + + /// + /// Describes a tool parameter + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] + public class ToolParameterAttribute : Attribute + { + /// + /// Parameter name (if null, derived from property/field name) + /// + public string Name { get; } + + /// + /// Parameter description for LLM + /// + public string Description { get; set; } + + /// + /// Whether this parameter is required + /// + public bool Required { get; set; } = true; + + /// + /// Default value (as string) + /// + public string DefaultValue { get; set; } + + public ToolParameterAttribute(string description) + { + Description = description; } } } diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 9e68d20..d30053e 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -9,7 +9,7 @@ using UnityEngine.SceneManagement; namespace MCPForUnity.Editor.Tools.Prefabs { - [McpForUnityTool("manage_prefabs")] + [McpForUnityTool("manage_prefabs", AutoRegister = false)] public static class ManagePrefabs { private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; @@ -18,13 +18,13 @@ namespace MCPForUnity.Editor.Tools.Prefabs { if (@params == null) { - return Response.Error("Parameters cannot be null."); + return new ErrorResponse("Parameters cannot be null."); } string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { - return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}."); + return new ErrorResponse($"Action parameter is required. Valid actions are: {SupportedActions}."); } try @@ -40,13 +40,13 @@ namespace MCPForUnity.Editor.Tools.Prefabs case "create_from_gameobject": return CreatePrefabFromGameObject(@params); default: - return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); + return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); } } catch (Exception e) { McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); - return Response.Error($"Internal error: {e.Message}"); + return new ErrorResponse($"Internal error: {e.Message}"); } } @@ -55,29 +55,29 @@ namespace MCPForUnity.Editor.Tools.Prefabs string prefabPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) { - return Response.Error("'prefabPath' parameter is required for open_stage."); + return new ErrorResponse("'prefabPath' parameter is required for open_stage."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { - return Response.Error($"No prefab asset found at path '{sanitizedPath}'."); + return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); } string modeValue = @params["mode"]?.ToString(); if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) { - return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time."); + return new ErrorResponse("Only PrefabStage mode 'InIsolation' is supported at this time."); } PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); if (stage == null) { - return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'."); + return new ErrorResponse($"Failed to open prefab stage for '{sanitizedPath}'."); } - return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); + return new SuccessResponse($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); } private static object CloseStage(JObject @params) @@ -85,7 +85,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) { - return Response.Success("No prefab stage was open."); + return new SuccessResponse("No prefab stage was open."); } bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; @@ -96,7 +96,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs } StageUtility.GoToMainStage(); - return Response.Success($"Closed prefab stage for '{stage.assetPath}'."); + return new SuccessResponse($"Closed prefab stage for '{stage.assetPath}'."); } private static object SaveOpenStage() @@ -104,12 +104,12 @@ namespace MCPForUnity.Editor.Tools.Prefabs PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) { - return Response.Error("No prefab stage is currently open."); + return new ErrorResponse("No prefab stage is currently open."); } SaveStagePrefab(stage); AssetDatabase.SaveAssets(); - return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); + return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); } private static void SaveStagePrefab(PrefabStage stage) @@ -131,19 +131,19 @@ namespace MCPForUnity.Editor.Tools.Prefabs string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); if (string.IsNullOrEmpty(targetName)) { - return Response.Error("'target' parameter is required for create_from_gameobject."); + return new ErrorResponse("'target' parameter is required for create_from_gameobject."); } bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); if (sourceObject == null) { - return Response.Error($"GameObject '{targetName}' not found in the active scene."); + return new ErrorResponse($"GameObject '{targetName}' not found in the active scene."); } if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) { - return Response.Error( + return new ErrorResponse( $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." ); } @@ -151,7 +151,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); if (status != PrefabInstanceStatus.NotAPrefab) { - return Response.Error( + return new ErrorResponse( $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." ); } @@ -159,7 +159,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs string requestedPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrWhiteSpace(requestedPath)) { - return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); + return new ErrorResponse("'prefabPath' parameter is required for create_from_gameobject."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); @@ -188,12 +188,12 @@ namespace MCPForUnity.Editor.Tools.Prefabs if (connectedInstance == null) { - return Response.Error($"Failed to save prefab asset at '{finalPath}'."); + return new ErrorResponse($"Failed to save prefab asset at '{finalPath}'."); } Selection.activeGameObject = connectedInstance; - return Response.Success( + return new SuccessResponse( $"Prefab created at '{finalPath}' and instance linked.", new { @@ -204,7 +204,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs } catch (Exception e) { - return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); + return new ErrorResponse($"Error saving prefab asset at '{finalPath}': {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index e94e5d5..7a5046a 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Tools /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// - [McpForUnityTool("read_console")] + [McpForUnityTool("read_console", AutoRegister = false)] public static class ReadConsole { // (Calibration removed) @@ -147,7 +147,7 @@ namespace MCPForUnity.Editor.Tools Debug.LogError( "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." ); - return Response.Error( + return new ErrorResponse( "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." ); } @@ -190,7 +190,7 @@ namespace MCPForUnity.Editor.Tools } else { - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." ); } @@ -198,7 +198,7 @@ namespace MCPForUnity.Editor.Tools catch (Exception e) { Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); } } @@ -209,12 +209,12 @@ namespace MCPForUnity.Editor.Tools try { _clearMethod.Invoke(null, null); // Static method, no instance, no parameters - return Response.Success("Console cleared successfully."); + return new SuccessResponse("Console cleared successfully."); } catch (Exception e) { Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); - return Response.Error($"Failed to clear console: {e.Message}"); + return new ErrorResponse($"Failed to clear console: {e.Message}"); } } @@ -359,7 +359,7 @@ namespace MCPForUnity.Editor.Tools catch { /* Ignore nested exception */ } - return Response.Error($"Error retrieving log entries: {e.Message}"); + return new ErrorResponse($"Error retrieving log entries: {e.Message}"); } finally { @@ -376,7 +376,7 @@ namespace MCPForUnity.Editor.Tools } // Return the filtered and formatted list (might be empty) - return Response.Success( + return new SuccessResponse( $"Retrieved {formattedEntries.Count} log entries.", formattedEntries ); diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index 74dac6a..f49e57f 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -10,7 +10,7 @@ namespace MCPForUnity.Editor.Tools /// /// Executes Unity tests for a specified mode and returns detailed results. /// - [McpForUnityTool("run_tests")] + [McpForUnityTool("run_tests", AutoRegister = false)] public static class RunTests { private const int DefaultTimeoutSeconds = 600; // 10 minutes @@ -25,7 +25,7 @@ namespace MCPForUnity.Editor.Tools if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) { - return Response.Error(parseError); + return new ErrorResponse(parseError); } int timeoutSeconds = DefaultTimeoutSeconds; @@ -50,7 +50,7 @@ namespace MCPForUnity.Editor.Tools } catch (Exception ex) { - return Response.Error($"Failed to start test run: {ex.Message}"); + return new ErrorResponse($"Failed to start test run: {ex.Message}"); } var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds)); @@ -58,7 +58,7 @@ namespace MCPForUnity.Editor.Tools if (completed != runTask) { - return Response.Error($"Test run timed out after {timeoutSeconds} seconds"); + return new ErrorResponse($"Test run timed out after {timeoutSeconds} seconds"); } var result = await runTask.ConfigureAwait(true); @@ -67,7 +67,7 @@ namespace MCPForUnity.Editor.Tools $"{parsedMode.Value} tests completed: {result.Passed}/{result.Total} passed, {result.Failed} failed, {result.Skipped} skipped"; var data = result.ToSerializable(parsedMode.Value.ToString()); - return Response.Success(message, data); + return new SuccessResponse(message, data); } } } diff --git a/MCPForUnity/Editor/Windows/Components.meta b/MCPForUnity/Editor/Windows/Components.meta new file mode 100644 index 0000000..716e0fa --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82074be914aefa84cb557c599d2319b3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig.meta b/MCPForUnity/Editor/Windows/Components/ClientConfig.meta new file mode 100644 index 0000000..58abdee --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4d9f5ceeb24166f47804e094440b7846 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs new file mode 100644 index 0000000..462ed4a --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -0,0 +1,321 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Windows.Components.ClientConfig +{ + /// + /// Controller for the Client Configuration section of the MCP For Unity editor window. + /// Handles client selection, configuration, status display, and manual configuration details. + /// + public class McpClientConfigSection + { + // UI Elements + private DropdownField clientDropdown; + private Button configureAllButton; + private VisualElement clientStatusIndicator; + private Label clientStatusLabel; + private Button configureButton; + private VisualElement claudeCliPathRow; + private TextField claudeCliPath; + private Button browseClaudeButton; + private TextField configPathField; + private Button copyPathButton; + private Button openFileButton; + private TextField configJsonField; + private Button copyJsonButton; + private Label installationStepsLabel; + + // Data + private readonly McpClients mcpClients; + private int selectedClientIndex = 0; + + public VisualElement Root { get; private set; } + + public McpClientConfigSection(VisualElement root, McpClients clients) + { + Root = root; + mcpClients = clients; + CacheUIElements(); + InitializeUI(); + RegisterCallbacks(); + } + + private void CacheUIElements() + { + clientDropdown = Root.Q("client-dropdown"); + configureAllButton = Root.Q