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 <david@lighthaus.us> * 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<byte>.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 <david@lighthaus.us> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>main
parent
edd7817d40
commit
a034ab0b21
|
|
@ -0,0 +1,6 @@
|
|||
Server/build
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.DS_Store
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ build/
|
|||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
UnityMcpServer/**/*.meta
|
||||
UnityMcpServer.meta
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b104663d2f6c648e1b99633082385db2
|
||||
guid: f7e009cbf3e74f6c987331c2b438ec59
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
namespace MCPForUnity.Editor.Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized list of EditorPrefs keys used by the MCP for Unity package.
|
||||
/// Keeping them in one place avoids typos and simplifies migrations.
|
||||
/// </summary>
|
||||
internal static class EditorPrefKeys
|
||||
{
|
||||
internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport";
|
||||
internal const string 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c40bd28f2310d463c8cd00181202cbe4
|
||||
guid: 7317786cfb9304b0db20ca73a774b9fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of Python tool files to sync to the MCP server.
|
||||
/// Add your Python files here - they can be stored anywhere in your project.
|
||||
/// </summary>
|
||||
[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<TextAsset> pythonFiles = new List<TextAsset>();
|
||||
|
||||
[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<PythonFileState> fileStates = new List<PythonFileState>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all valid Python files (filters out null/missing references)
|
||||
/// </summary>
|
||||
public IEnumerable<TextAsset> GetValidFiles()
|
||||
{
|
||||
return pythonFiles.Where(f => f != null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file needs syncing
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a file was synced
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes state entries for files no longer in the list
|
||||
/// </summary>
|
||||
public void CleanupStaleStates()
|
||||
{
|
||||
var validGuids = new HashSet<string>(GetValidFiles().Select(GetAssetGuid));
|
||||
fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid));
|
||||
}
|
||||
|
||||
private string GetAssetGuid(TextAsset asset)
|
||||
{
|
||||
return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the asset is modified in the Inspector
|
||||
/// Triggers sync to handle file additions/removals
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,9 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
|
|||
DependencyStatus DetectPython();
|
||||
|
||||
/// <summary>
|
||||
/// Detect UV package manager on this platform
|
||||
/// Detect uv package manager on this platform
|
||||
/// </summary>
|
||||
DependencyStatus DetectUV();
|
||||
|
||||
/// <summary>
|
||||
/// Detect MCP server installation on this platform
|
||||
/// </summary>
|
||||
DependencyStatus DetectMCPServer();
|
||||
DependencyStatus DetectUv();
|
||||
|
||||
/// <summary>
|
||||
/// Get platform-specific installation recommendations
|
||||
|
|
@ -43,8 +38,8 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
|
|||
string GetPythonInstallUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Get platform-specific UV installation URL
|
||||
/// Get platform-specific uv installation URL
|
||||
/// </summary>
|
||||
string GetUVInstallUrl();
|
||||
string GetUvInstallUrl();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -136,7 +138,45 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <returns>Git URL string, or empty string if version is unknown and no override</returns>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets structured uvx command parts for different client configurations
|
||||
/// </summary>
|
||||
/// <returns>Tuple containing (uvxPath, fromUrl, packageName)</returns>
|
||||
public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts()
|
||||
{
|
||||
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
|
||||
string fromUrl = GetMcpServerGitUrl();
|
||||
string packageName = "mcp-for-unity";
|
||||
|
||||
return (uvxPath, fromUrl, packageName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the package version from package.json
|
||||
/// </summary>
|
||||
/// <returns>Version string, or "unknown" if not found</returns>
|
||||
public static string GetPackageVersion()
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// </summary>
|
||||
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
|
|||
/// <summary>
|
||||
/// Creates a TomlTable for the unityMCP server configuration
|
||||
/// </summary>
|
||||
/// <param name="uvPath">Path to uv executable</param>
|
||||
/// <param name="serverSrc">Path to server source directory</param>
|
||||
private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc)
|
||||
/// <param name="uvPath">Path to uv executable (used as fallback if uvx is not available)</param>
|
||||
private static TomlTable CreateUnityMcpTable(string uvPath)
|
||||
{
|
||||
var unityMCP = new TomlTable();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
|||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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<string> { 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using UnityEditor;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper methods for managing HTTP endpoint URLs used by the MCP bridge.
|
||||
/// Ensures the stored value is always the base URL (without trailing path),
|
||||
/// and provides convenience accessors for specific endpoints.
|
||||
/// </summary>
|
||||
public static class HttpEndpointUtility
|
||||
{
|
||||
private const string PrefKey = EditorPrefKeys.HttpBaseUrl;
|
||||
private const string DefaultBaseUrl = "http://localhost:8080";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the normalized base URL currently stored in EditorPrefs.
|
||||
/// </summary>
|
||||
public static string GetBaseUrl()
|
||||
{
|
||||
string stored = EditorPrefs.GetString(PrefKey, DefaultBaseUrl);
|
||||
return NormalizeBaseUrl(stored);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a user-provided URL after normalizing it to a base form.
|
||||
/// </summary>
|
||||
public static void SaveBaseUrl(string userValue)
|
||||
{
|
||||
string normalized = NormalizeBaseUrl(userValue);
|
||||
EditorPrefs.SetString(PrefKey, normalized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the JSON-RPC endpoint used by FastMCP clients (base + /mcp).
|
||||
/// </summary>
|
||||
public static string GetMcpRpcUrl()
|
||||
{
|
||||
return AppendPathSegment(GetBaseUrl(), "mcp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the endpoint used when POSTing custom-tool registration payloads.
|
||||
/// </summary>
|
||||
public static string GetRegisterToolsUrl()
|
||||
{
|
||||
return AppendPathSegment(GetBaseUrl(), "register-tools");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a URL so that we consistently store just the base (no trailing slash/path).
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1ad9865b38bcc4efe85d4970c6d3a997
|
||||
guid: 2051d90316ea345c09240c80c7138e3b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -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
|
|||
/// </summary>
|
||||
public static class McpConfigurationHelper
|
||||
{
|
||||
private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig";
|
||||
private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Writes MCP configuration to the specified path using sophisticated logic
|
||||
/// that preserves existing configuration and only writes when necessary
|
||||
/// </summary>
|
||||
public static string WriteMcpConfiguration(string 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a Codex client with sophisticated TOML handling
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates UV binary by running --version command
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appropriate config file path for the given MCP client based on OS
|
||||
/// </summary>
|
||||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the server directory to use for MCP tools, preferring
|
||||
/// existing config values and falling back to installed/embedded copies.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility for persisting tool state across domain reloads. State is stored in
|
||||
/// Library so it stays local to the project and is cleared by Unity as needed.
|
||||
/// </summary>
|
||||
public static class McpJobStateStore
|
||||
{
|
||||
private static string GetStatePath(string toolName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(toolName))
|
||||
{
|
||||
throw new ArgumentException("toolName cannot be null or empty", nameof(toolName));
|
||||
}
|
||||
|
||||
var libraryPath = Path.Combine(Application.dataPath, "..", "Library");
|
||||
var fileName = $"McpState_{toolName}.json";
|
||||
return Path.GetFullPath(Path.Combine(libraryPath, fileName));
|
||||
}
|
||||
|
||||
public static void SaveState<T>(string toolName, T state)
|
||||
{
|
||||
var path = GetStatePath(toolName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance<T>());
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
|
||||
public static T LoadState<T>(string toolName)
|
||||
{
|
||||
var path = GetStatePath(toolName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonConvert.DeserializeObject<T>(json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearState(string toolName)
|
||||
{
|
||||
var path = GetStatePath(toolName);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4bdcf382960c842aab0a08c90411ab43
|
||||
guid: 28912085dd68342f8a9fda8a43c83a59
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -1,33 +1,53 @@
|
|||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
internal static class McpLog
|
||||
{
|
||||
private const string LogPrefix = "<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>:";
|
||||
private const string InfoPrefix = "<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>:";
|
||||
private const string DebugPrefix = "<b><color=#6AA84F>MCP-FOR-UNITY</color></b>:";
|
||||
private const string WarnPrefix = "<b><color=#cc7a00>MCP-FOR-UNITY</color></b>:";
|
||||
private const string ErrorPrefix = "<b><color=#cc3333>MCP-FOR-UNITY</color></b>:";
|
||||
|
||||
private static 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared helper for resolving MCP server directory paths with support for
|
||||
/// development mode, embedded servers, and installed packages
|
||||
/// </summary>
|
||||
public static class McpPathResolver
|
||||
{
|
||||
private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the MCP server directory path with comprehensive logic
|
||||
/// including development mode support and fallback mechanisms
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current Unity project is in development mode
|
||||
/// (i.e., the package is referenced as a local file path in manifest.json)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appropriate PATH prepend for the current platform when running external processes
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages package lifecycle events including first-time installation,
|
||||
/// version updates, and legacy installation detection.
|
||||
/// Consolidates the functionality of PackageInstaller and PackageDetector.
|
||||
/// </summary>
|
||||
[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 { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string GetLastInstallError()
|
||||
{
|
||||
try
|
||||
{
|
||||
string currentVersion = GetPackageVersion();
|
||||
string errorKey = InstallErrorKeyPrefix + currentVersion;
|
||||
if (EditorPrefs.HasKey(errorKey))
|
||||
{
|
||||
return EditorPrefs.GetString(errorKey, null);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the last installation error. Should be called after a successful manual rebuild.
|
||||
/// </summary>
|
||||
public static void ClearLastInstallError()
|
||||
{
|
||||
try
|
||||
{
|
||||
string currentVersion = GetPackageVersion();
|
||||
string errorKey = InstallErrorKeyPrefix + currentVersion;
|
||||
if (EditorPrefs.HasKey(errorKey))
|
||||
{
|
||||
EditorPrefs.DeleteKey(errorKey);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>Port number to use</returns>
|
||||
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($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -81,10 +60,30 @@ namespace MCPForUnity.Editor.Helpers
|
|||
{
|
||||
int newPort = FindAvailablePort();
|
||||
SavePort(newPort);
|
||||
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Discovered and saved new port: {newPort}");
|
||||
if (IsDebugEnabled()) McpLog.Info($"Discovered and saved new port: {newPort}");
|
||||
return newPort;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist a user-selected port and return the value actually stored.
|
||||
/// If <paramref name="port"/> is unavailable, the next available port is chosen instead.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find an available port starting from the default port
|
||||
/// </summary>
|
||||
|
|
@ -94,18 +93,18 @@ namespace MCPForUnity.Editor.Helpers
|
|||
// Always try default port first
|
||||
if (IsPortAvailable(DefaultPort))
|
||||
{
|
||||
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using default port {DefaultPort}");
|
||||
if (IsDebugEnabled()) McpLog.Info($"Using default port {DefaultPort}");
|
||||
return DefaultPort;
|
||||
}
|
||||
|
||||
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides shared utilities for deriving deterministic project identity information
|
||||
/// used by transport clients (hash, name, persistent session id).
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string GetProjectHash()
|
||||
{
|
||||
EnsureIdentityCache();
|
||||
return _cachedProjectHash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human friendly project name derived from the Assets directory path,
|
||||
/// or "Unknown" if the name cannot be determined.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a server-assigned session id.
|
||||
/// Safe to call from background threads.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a persistent session id for the plugin, creating one if absent.
|
||||
/// The session id is unique per project (scoped by project hash).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the persisted session id (mainly for tests).
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2c76f0c7ff138ba4a952481e04bc3974
|
||||
guid: 936e878ce1275453bae5e0cf03bd9d30
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically syncs Python tools to the MCP server when:
|
||||
/// - PythonToolsAsset is modified
|
||||
/// - Python files are imported/reimported
|
||||
/// - Unity starts up
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after any assets are imported, deleted, or moved
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs all Python tools from all PythonToolsAsset instances to the MCP server
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if auto-sync is enabled (default: true)
|
||||
/// </summary>
|
||||
public static bool IsAutoSyncEnabled()
|
||||
{
|
||||
return EditorPrefs.GetBool(SyncEnabledKey, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables auto-sync
|
||||
/// </summary>
|
||||
public static void SetAutoSyncEnabled(bool enabled)
|
||||
{
|
||||
EditorPrefs.SetBool(SyncEnabledKey, enabled);
|
||||
McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reimport all Python files in the project
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger sync
|
||||
/// </summary>
|
||||
public static void ManualSync()
|
||||
{
|
||||
McpLog.Info("Manually syncing Python tools...");
|
||||
SyncAllTools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle auto-sync
|
||||
/// </summary>
|
||||
public static void ToggleAutoSync()
|
||||
{
|
||||
SetAutoSyncEnabled(!IsAutoSyncEnabled());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate menu item (shows checkmark when enabled)
|
||||
/// </summary>
|
||||
public static bool ToggleAutoSyncValidate()
|
||||
{
|
||||
Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +1,108 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides static methods for creating standardized success and error response objects.
|
||||
/// Ensures consistent JSON structure for communication back to the Python server.
|
||||
/// </summary>
|
||||
public static class Response
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a standardized success response object.
|
||||
/// </summary>
|
||||
/// <param name="message">A message describing the successful operation.</param>
|
||||
/// <param name="data">Optional additional data to include in the response.</param>
|
||||
/// <returns>An object representing the success response.</returns>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standardized error response object.
|
||||
/// </summary>
|
||||
/// <param name="errorCodeOrMessage">A message describing the error.</param>
|
||||
/// <param name="data">Optional additional data (e.g., error details) to include.</param>
|
||||
/// <returns>An object representing the error response.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5862c6a6d0a914f4d83224f8d039cf7b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
public static class ServerPathResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
|||
/// </summary>
|
||||
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<Dictionary<string, object>> s_sender;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -140,8 +142,8 @@ namespace MCPForUnity.Editor.Helpers
|
|||
{
|
||||
RecordEvent("bridge_startup", new Dictionary<string, object>
|
||||
{
|
||||
["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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
using UnityEngine;
|
||||
using UnityEditor.AssetImporters;
|
||||
using System.IO;
|
||||
|
||||
namespace MCPForUnity.Editor.Importers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d68ef794590944f1ea7ee102c91887c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -9,9 +9,10 @@
|
|||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"precompiledReferences": [
|
||||
"Newtonsoft.Json.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 96dc847eb7f7a45e0b91241db934a4be
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Setup;
|
||||
using MCPForUnity.Editor.Windows;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized menu items for MCP For Unity
|
||||
/// </summary>
|
||||
public static class MCPForUnityMenu
|
||||
{
|
||||
// ========================================
|
||||
// Main Menu Items
|
||||
// ========================================
|
||||
|
||||
/// <summary>
|
||||
/// Show the setup wizard
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)]
|
||||
public static void ShowSetupWizard()
|
||||
{
|
||||
SetupWizard.ShowSetupWizard();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open the main MCP For Unity window
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Open MCP Window %#m", priority = 2)]
|
||||
public static void OpenMCPWindow()
|
||||
{
|
||||
MCPForUnityEditorWindow.ShowWindow();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Tool Sync Menu Items
|
||||
// ========================================
|
||||
|
||||
/// <summary>
|
||||
/// Reimport all Python files in the project
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)]
|
||||
public static void ReimportPythonFiles()
|
||||
{
|
||||
PythonToolSyncProcessor.ReimportPythonFiles();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually sync Python tools to the MCP server
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)]
|
||||
public static void SyncPythonTools()
|
||||
{
|
||||
PythonToolSyncProcessor.ManualSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle auto-sync for Python tools
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)]
|
||||
public static void ToggleAutoSync()
|
||||
{
|
||||
PythonToolSyncProcessor.ToggleAutoSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate menu item (shows checkmark when auto-sync is enabled)
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)]
|
||||
public static bool ToggleAutoSyncValidate()
|
||||
{
|
||||
return PythonToolSyncProcessor.ToggleAutoSyncValidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 02a6714b521ec47868512a8db433975c
|
||||
guid: 9e7f37616736f4d3cbd8bdbc626f5ab9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
using MCPForUnity.Editor.Setup;
|
||||
using MCPForUnity.Editor.Windows;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.MenuItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized menu items for MCP For Unity
|
||||
/// </summary>
|
||||
public static class MCPForUnityMenu
|
||||
{
|
||||
// ========================================
|
||||
// Main Menu Items
|
||||
// ========================================
|
||||
|
||||
/// <summary>
|
||||
/// Show the Setup Window
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Setup Window", priority = 1)]
|
||||
public static void ShowSetupWindow()
|
||||
{
|
||||
SetupWindowService.ShowSetupWindow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle the main MCP For Unity window
|
||||
/// </summary>
|
||||
[MenuItem("Window/MCP For Unity/Toggle MCP Window %#m", priority = 2)]
|
||||
public static void ToggleMCPWindow()
|
||||
{
|
||||
if (EditorWindow.HasOpenInstances<MCPForUnityEditorWindow>())
|
||||
{
|
||||
foreach (var window in UnityEngine.Resources.FindObjectsOfTypeAll<MCPForUnityEditorWindow>())
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
MCPForUnityEditorWindow.ShowWindow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0c392d9059b864f608a4d32e4347c3d6
|
||||
guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects legacy embedded-server preferences and migrates configs to the new uvx/stdio path once.
|
||||
/// </summary>
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4436b2149abf4b0d8014f81cd29a2bd0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps stdio MCP clients in sync with the current package version by rewriting their configs when the package updates.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f1d589c8c8684e6f919ffb393c4b4db5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ namespace MCPForUnity.Editor.Resources.Editor
|
|||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ namespace MCPForUnity.Editor.Resources.Editor
|
|||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> GetMenuItemsInternal(bool forceRefresh)
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ namespace MCPForUnity.Editor.Resources.Project
|
|||
}
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ namespace MCPForUnity.Editor.Resources.Project
|
|||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementation of bridge control service
|
||||
/// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio).
|
||||
/// </summary>
|
||||
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<bool> 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<BridgeVerificationResult> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: {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<JToken>(configJson) as JObject;
|
||||
if (vsConfig != null)
|
||||
{
|
||||
args = vsConfig.servers.unityMCP.args.ToObject<string[]>();
|
||||
configExists = true;
|
||||
}
|
||||
else if (vsConfig?.mcp?.servers?.unityMCP != null)
|
||||
{
|
||||
args = vsConfig.mcp.servers.unityMCP.args.ToObject<string[]>();
|
||||
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<string[]>();
|
||||
}
|
||||
|
||||
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("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Successfully registered with Claude Code.");
|
||||
McpLog.Info("Successfully registered with Claude Code.");
|
||||
|
||||
// Update status
|
||||
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
|
||||
|
|
@ -323,14 +334,14 @@ namespace MCPForUnity.Editor.Services
|
|||
{
|
||||
claudeClient.SetStatus(McpStatus.NotConfigured);
|
||||
}
|
||||
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge.
|
||||
/// </summary>
|
||||
[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}");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4c0cf970a7b494a659be151dc0124296
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -21,14 +24,20 @@ namespace MCPForUnity.Editor.Services
|
|||
bool IsAutoConnectMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MCP for Unity Bridge
|
||||
/// Gets the currently active transport mode, if any
|
||||
/// </summary>
|
||||
void Start();
|
||||
TransportMode? ActiveMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Stops the MCP for Unity Bridge
|
||||
/// Starts the MCP for Unity Bridge asynchronously
|
||||
/// </summary>
|
||||
void Stop();
|
||||
/// <returns>True if the bridge started successfully</returns>
|
||||
Task<bool> StartAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Stops the MCP for Unity Bridge asynchronously
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the bridge connection by sending a ping and waiting for a pong response
|
||||
|
|
@ -36,6 +45,13 @@ namespace MCPForUnity.Editor.Services
|
|||
/// <param name="port">The port to verify</param>
|
||||
/// <returns>Verification result with detailed status</returns>
|
||||
BridgeVerificationResult Verify(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the connection asynchronously (works for both HTTP and stdio transports)
|
||||
/// </summary>
|
||||
/// <returns>Verification result with detailed status</returns>
|
||||
Task<BridgeVerificationResult> VerifyAsync();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -6,16 +6,10 @@ namespace MCPForUnity.Editor.Services
|
|||
public interface IPathResolverService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the MCP server path (respects override if set)
|
||||
/// Gets the uvx package manager path (respects override if set)
|
||||
/// </summary>
|
||||
/// <returns>Path to the MCP server directory containing server.py, or null if not found</returns>
|
||||
string GetMcpServerPath();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UV package manager path (respects override if set)
|
||||
/// </summary>
|
||||
/// <returns>Path to the uv executable, or null if not found</returns>
|
||||
string GetUvPath();
|
||||
/// <returns>Path to the uvx executable, or null if not found</returns>
|
||||
string GetUvxPath();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Claude CLI path (respects override if set)
|
||||
|
|
@ -29,12 +23,6 @@ namespace MCPForUnity.Editor.Services
|
|||
/// <returns>True if Python is found</returns>
|
||||
bool IsPythonDetected();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if UV is detected on the system
|
||||
/// </summary>
|
||||
/// <returns>True if UV is found</returns>
|
||||
bool IsUvDetected();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Claude CLI is detected on the system
|
||||
/// </summary>
|
||||
|
|
@ -42,16 +30,10 @@ namespace MCPForUnity.Editor.Services
|
|||
bool IsClaudeCliDetected();
|
||||
|
||||
/// <summary>
|
||||
/// Sets an override for the MCP server path
|
||||
/// Sets an override for the uvx path
|
||||
/// </summary>
|
||||
/// <param name="path">Path to override with</param>
|
||||
void SetMcpServerOverride(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Sets an override for the UV path
|
||||
/// </summary>
|
||||
/// <param name="path">Path to override with</param>
|
||||
void SetUvPathOverride(string path);
|
||||
void SetUvxPathOverride(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Sets an override for the Claude CLI path
|
||||
|
|
@ -60,14 +42,9 @@ namespace MCPForUnity.Editor.Services
|
|||
void SetClaudeCliPathOverride(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the MCP server path override
|
||||
/// Clears the uvx path override
|
||||
/// </summary>
|
||||
void ClearMcpServerOverride();
|
||||
|
||||
/// <summary>
|
||||
/// Clears the UV path override
|
||||
/// </summary>
|
||||
void ClearUvPathOverride();
|
||||
void ClearUvxPathOverride();
|
||||
|
||||
/// <summary>
|
||||
/// Clears the Claude CLI path override
|
||||
|
|
@ -75,14 +52,9 @@ namespace MCPForUnity.Editor.Services
|
|||
void ClearClaudeCliPathOverride();
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a MCP server path override is active
|
||||
/// Gets whether a uvx path override is active
|
||||
/// </summary>
|
||||
bool HasMcpServerOverride { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a UV path override is active
|
||||
/// </summary>
|
||||
bool HasUvPathOverride { get; }
|
||||
bool HasUvxPathOverride { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a Claude CLI path override is active
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Data;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public interface IPythonToolRegistryService
|
||||
{
|
||||
IEnumerable<PythonToolsAsset> GetAllRegistries();
|
||||
bool NeedsSync(PythonToolsAsset registry, TextAsset file);
|
||||
void RecordSync(PythonToolsAsset registry, TextAsset file);
|
||||
string ComputeHash(TextAsset file);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a2487319df5cc47baa2c635b911038c5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for server management operations
|
||||
/// </summary>
|
||||
public interface IServerManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Clear the local uvx cache for the MCP server package
|
||||
/// </summary>
|
||||
/// <returns>True if successful, false otherwise</returns>
|
||||
bool ClearUvxCache();
|
||||
|
||||
/// <summary>
|
||||
/// Start the local HTTP server in a new terminal window.
|
||||
/// Stops any existing server on the port and clears the uvx cache first.
|
||||
/// </summary>
|
||||
/// <returns>True if server was started successfully, false otherwise</returns>
|
||||
bool StartLocalHttpServer();
|
||||
|
||||
/// <summary>
|
||||
/// Stop the local HTTP server by finding the process listening on the configured port
|
||||
/// </summary>
|
||||
bool StopLocalHttpServer();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the command that will be executed when starting the local HTTP server
|
||||
/// </summary>
|
||||
/// <param name="command">The command that will be executed when available</param>
|
||||
/// <param name="error">Reason why a command could not be produced</param>
|
||||
/// <returns>True if a command is available, false otherwise</returns>
|
||||
bool TryGetLocalHttpServerCommand(out string command, out string error);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the configured HTTP URL is a local address
|
||||
/// </summary>
|
||||
/// <returns>True if URL is local (localhost, 127.0.0.1, etc.)</returns>
|
||||
bool IsLocalUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Check if the local HTTP server can be started
|
||||
/// </summary>
|
||||
/// <returns>True if HTTP transport is enabled and URL is local</returns>
|
||||
bool CanStartLocalServer();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d41bfc9780b774affa6afbffd081eb79
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Metadata for a discovered tool
|
||||
/// </summary>
|
||||
public class ToolMetadata
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public bool StructuredOutput { get; set; }
|
||||
public List<ParameterMetadata> 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a tool parameter
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for discovering MCP tools via reflection
|
||||
/// </summary>
|
||||
public interface IToolDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers all tools marked with [McpForUnityTool]
|
||||
/// </summary>
|
||||
List<ToolMetadata> DiscoverAllTools();
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for a specific tool
|
||||
/// </summary>
|
||||
ToolMetadata GetToolMetadata(string toolName);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the tool discovery cache
|
||||
/// </summary>
|
||||
void InvalidateCache();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 497592a93fd994b2cb9803e7c8636ff7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<string> Messages { get; set; } = new List<string>();
|
||||
public bool Success => ErrorCount == 0;
|
||||
}
|
||||
|
||||
public interface IToolSyncService
|
||||
{
|
||||
ToolSyncResult SyncProjectTools(string destToolsDir);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b9627dbaa92d24783a9f20e42efcea18
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PythonToolsAsset> 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<PythonToolsAsset>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2da2869749c764f16a45e010eefbd679
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for managing MCP server lifecycle
|
||||
/// </summary>
|
||||
public class ServerManagementService : IServerManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Clear the local uvx cache for the MCP server package
|
||||
/// </summary>
|
||||
/// <returns>True if successful, false otherwise</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start the local HTTP server in a new terminal window.
|
||||
/// Stops any existing server on the port and clears the uvx cache first.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop the local HTTP server by finding the process listening on the configured port
|
||||
/// </summary>
|
||||
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 :<port>
|
||||
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 :<port> -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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to build the command used for starting the local HTTP server
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the configured HTTP URL is a local address
|
||||
/// </summary>
|
||||
public bool IsLocalUrl()
|
||||
{
|
||||
string httpUrl = HttpEndpointUtility.GetBaseUrl();
|
||||
return IsLocalUrl(httpUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the local HTTP server can be started
|
||||
/// </summary>
|
||||
public bool CanStartLocalServer()
|
||||
{
|
||||
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||
return useHttpTransport && IsLocalUrl();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ProcessStartInfo for opening a terminal window with the given command
|
||||
/// Works cross-platform: macOS, Windows, and Linux
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8e60df35c5a76462d8aaa8078da86d75
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<string, ToolMetadata> _cachedTools;
|
||||
|
||||
public List<ToolMetadata> DiscoverAllTools()
|
||||
{
|
||||
if (_cachedTools != null)
|
||||
{
|
||||
return _cachedTools.Values.ToList();
|
||||
}
|
||||
|
||||
_cachedTools = new Dictionary<string, ToolMetadata>();
|
||||
|
||||
// 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<McpForUnityToolAttribute>();
|
||||
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<ParameterMetadata> ExtractParameters(Type type)
|
||||
{
|
||||
var parameters = new List<ParameterMetadata>();
|
||||
|
||||
// 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<ToolParameterAttribute>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ec81a561be4c14c9cb243855d3273a94
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<string>();
|
||||
|
||||
// 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<string> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ad084cf3b6c04174b9202bf63137bae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d5876265244e44b0dbea3a1351bf24be
|
||||
guid: 8d189635a5d364f55a810203798c09ba
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction for MCP transport implementations (e.g. WebSocket push, stdio).
|
||||
/// </summary>
|
||||
public interface IMcpTransportClient
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
string TransportName { get; }
|
||||
TransportState State { get; }
|
||||
|
||||
Task<bool> StartAsync();
|
||||
Task StopAsync();
|
||||
Task<bool> VerifyAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 042446a50a4744170bb294acf827376f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class TransportCommandDispatcher
|
||||
{
|
||||
private sealed class PendingCommand
|
||||
{
|
||||
public PendingCommand(
|
||||
string commandJson,
|
||||
TaskCompletionSource<string> completionSource,
|
||||
CancellationToken cancellationToken,
|
||||
CancellationTokenRegistration registration)
|
||||
{
|
||||
CommandJson = commandJson;
|
||||
CompletionSource = completionSource;
|
||||
CancellationToken = cancellationToken;
|
||||
CancellationRegistration = registration;
|
||||
}
|
||||
|
||||
public string CommandJson { get; }
|
||||
public TaskCompletionSource<string> 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<string, PendingCommand> Pending = new();
|
||||
private static readonly object PendingLock = new();
|
||||
private static bool updateHooked;
|
||||
private static bool initialised;
|
||||
|
||||
/// <summary>
|
||||
/// Schedule a command for execution on the Unity main thread and await its JSON response.
|
||||
/// </summary>
|
||||
public static Task<string> 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<string>(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<Command>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 27407cc9c1ea0412d80b9f8964a5a29d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Coordinates the active transport client and exposes lifecycle helpers.
|
||||
/// </summary>
|
||||
public class TransportManager
|
||||
{
|
||||
private IMcpTransportClient _active;
|
||||
private TransportMode? _activeMode;
|
||||
private Func<IMcpTransportClient> _webSocketFactory;
|
||||
private Func<IMcpTransportClient> _stdioFactory;
|
||||
|
||||
public TransportManager()
|
||||
{
|
||||
Configure(
|
||||
() => new WebSocketTransportClient(MCPServiceLocator.ToolDiscovery),
|
||||
() => new StdioTransportClient());
|
||||
}
|
||||
|
||||
public IMcpTransportClient ActiveTransport => _active;
|
||||
public TransportMode? ActiveMode => _activeMode;
|
||||
|
||||
public void Configure(
|
||||
Func<IMcpTransportClient> webSocketFactory,
|
||||
Func<IMcpTransportClient> stdioFactory)
|
||||
{
|
||||
_webSocketFactory = webSocketFactory ?? throw new ArgumentNullException(nameof(webSocketFactory));
|
||||
_stdioFactory = stdioFactory ?? throw new ArgumentNullException(nameof(stdioFactory));
|
||||
}
|
||||
|
||||
public async Task<bool> 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<bool> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 65fc8ff4c9efb4fc98a0910ba7ca8b02
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
namespace MCPForUnity.Editor.Services.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight snapshot of a transport's runtime status for editor UI and diagnostics.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 67ab8e43f6a804698bb5b216cdef0645
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3d467a63b6fad42fa975c731af4b83b3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fd295cefe518e438693c12e9c7f37488
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Transport.Transports
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapts the existing TCP bridge into the transport abstraction.
|
||||
/// </summary>
|
||||
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<bool> 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<bool> VerifyAsync()
|
||||
{
|
||||
bool running = StdioBridgeHost.IsRunning;
|
||||
_state = running
|
||||
? TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort())
|
||||
: TransportState.Disconnected("stdio", "Bridge not running");
|
||||
return Task.FromResult(running);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b2743f3468d5f433dbf2220f0838d8d1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue