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
Marcus Sanatan 2025-11-24 23:21:06 -04:00 committed by GitHub
parent edd7817d40
commit a034ab0b21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
346 changed files with 12535 additions and 19568 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
Server/build
.git
.venv
__pycache__
*.pyc
.DS_Store

View File

@ -67,11 +67,19 @@ jobs:
jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp
mv MCPForUnity/package.json.tmp MCPForUnity/package.json mv MCPForUnity/package.json.tmp MCPForUnity/package.json
echo "Updating MCPForUnity/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" echo "Updating Server/pyproject.toml to $NEW_VERSION"
sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/UnityMcpServer~/src/pyproject.toml" sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "Server/pyproject.toml"
echo "Updating MCPForUnity/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" echo "Updating README.md version references to v$NEW_VERSION"
echo "$NEW_VERSION" > "MCPForUnity/UnityMcpServer~/src/server_version.txt" 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 - name: Commit and push changes
env: env:
@ -81,7 +89,7 @@ jobs:
set -euo pipefail set -euo pipefail
git config user.name "GitHub Actions" git config user.name "GitHub Actions"
git config user.email "actions@github.com" 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 if git diff --cached --quiet; then
echo "No version changes to commit." echo "No version changes to commit."
else else

View File

@ -55,14 +55,13 @@ jobs:
uv venv uv venv
echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
if [ -f MCPForUnity/UnityMcpServer~/src/pyproject.toml ]; then if [ -f Server/pyproject.toml ]; then
uv pip install -e MCPForUnity/UnityMcpServer~/src uv pip install -e Server
elif [ -f MCPForUnity/UnityMcpServer~/src/requirements.txt ]; then elif [ -f Server/requirements.txt ]; then
uv pip install -r MCPForUnity/UnityMcpServer~/src/requirements.txt uv pip install -r Server/requirements.txt
elif [ -f MCPForUnity/UnityMcpServer~/pyproject.toml ]; then else
uv pip install -e MCPForUnity/UnityMcpServer~/ echo "No MCP Python deps found (skipping)"
elif [ -f MCPForUnity/UnityMcpServer~/requirements.txt ]; then fi
uv pip install -r MCPForUnity/UnityMcpServer~/requirements.txt
else else
echo "No MCP Python deps found (skipping)" echo "No MCP Python deps found (skipping)"
fi fi
@ -217,7 +216,7 @@ jobs:
-stackTraceLogType Full \ -stackTraceLogType Full \
-projectPath /workspace/TestProjects/UnityMCPTests \ -projectPath /workspace/TestProjects/UnityMCPTests \
"${manual_args[@]}" \ "${manual_args[@]}" \
-executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect -executeMethod MCPForUnity.Editor.Services.Transport.Transports.StdioBridgeHost.StartAutoConnect
# ---------- Wait for Unity bridge ---------- # ---------- Wait for Unity bridge ----------
- name: Wait for Unity bridge (robust) - name: Wait for Unity bridge (robust)
@ -285,7 +284,7 @@ jobs:
"mcpServers": { "mcpServers": {
"unity": { "unity": {
"command": "uv", "command": "uv",
"args": ["run","--active","--directory","MCPForUnity/UnityMcpServer~/src","python","server.py"], "args": ["run","--active","--directory","Server","python","server.py"],
"transport": { "type": "stdio" }, "transport": { "type": "stdio" },
"env": { "env": {
"PYTHONUNBUFFERED": "1", "PYTHONUNBUFFERED": "1",

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: ["**"] branches: ["**"]
paths: paths:
- MCPForUnity/UnityMcpServer~/src/** - Server/**
- .github/workflows/python-tests.yml - .github/workflows/python-tests.yml
workflow_dispatch: {} workflow_dispatch: {}
@ -26,13 +26,13 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
cd MCPForUnity/UnityMcpServer~/src cd Server
uv sync uv sync
uv pip install -e ".[dev]" uv pip install -e ".[dev]"
- name: Run tests - name: Run tests
run: | run: |
cd MCPForUnity/UnityMcpServer~/src cd Server
uv run pytest tests/ -v --tb=short uv run pytest tests/ -v --tb=short
- name: Upload test results - name: Upload test results
@ -41,5 +41,5 @@ jobs:
with: with:
name: pytest-results name: pytest-results
path: | path: |
MCPForUnity/UnityMcpServer~/src/.pytest_cache/ Server/.pytest_cache/
MCPForUnity/UnityMcpServer~/src/tests/ Server/tests/

2
.gitignore vendored
View File

@ -16,8 +16,6 @@ build/
dist/ dist/
wheels/ wheels/
*.egg-info *.egg-info
UnityMcpServer/**/*.meta
UnityMcpServer.meta
# Virtual environments # Virtual environments
.venv .venv

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: b104663d2f6c648e1b99633082385db2 guid: f7e009cbf3e74f6c987331c2b438ec59
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@ -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";
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: c40bd28f2310d463c8cd00181202cbe4 guid: 7317786cfb9304b0db20ca73a774b9fa
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -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;
}
}

View File

@ -56,14 +56,10 @@ namespace MCPForUnity.Editor.Dependencies
var pythonStatus = detector.DetectPython(); var pythonStatus = detector.DetectPython();
result.Dependencies.Add(pythonStatus); result.Dependencies.Add(pythonStatus);
// Check UV // Check uv
var uvStatus = detector.DetectUV(); var uvStatus = detector.DetectUv();
result.Dependencies.Add(uvStatus); result.Dependencies.Add(uvStatus);
// Check MCP Server
var serverStatus = detector.DetectMCPServer();
result.Dependencies.Add(serverStatus);
// Generate summary and recommendations // Generate summary and recommendations
result.GenerateSummary(); result.GenerateSummary();
GenerateRecommendations(result, detector); GenerateRecommendations(result, detector);
@ -104,7 +100,7 @@ namespace MCPForUnity.Editor.Dependencies
try try
{ {
var detector = GetCurrentPlatformDetector(); var detector = GetCurrentPlatformDetector();
return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); return (detector.GetPythonInstallUrl(), detector.GetUvInstallUrl());
} }
catch catch
{ {
@ -128,9 +124,9 @@ namespace MCPForUnity.Editor.Dependencies
{ {
result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); 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") else if (dep.Name == "MCP Server")
{ {
@ -140,7 +136,7 @@ namespace MCPForUnity.Editor.Dependencies
if (result.GetMissingRequired().Count > 0) 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.");
} }
} }
} }

View File

@ -23,14 +23,9 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
DependencyStatus DetectPython(); DependencyStatus DetectPython();
/// <summary> /// <summary>
/// Detect UV package manager on this platform /// Detect uv package manager on this platform
/// </summary> /// </summary>
DependencyStatus DetectUV(); DependencyStatus DetectUv();
/// <summary>
/// Detect MCP server installation on this platform
/// </summary>
DependencyStatus DetectMCPServer();
/// <summary> /// <summary>
/// Get platform-specific installation recommendations /// Get platform-specific installation recommendations
@ -43,8 +38,8 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
string GetPythonInstallUrl(); string GetPythonInstallUrl();
/// <summary> /// <summary>
/// Get platform-specific UV installation URL /// Get platform-specific uv installation URL
/// </summary> /// </summary>
string GetUVInstallUrl(); string GetUvInstallUrl();
} }
} }

View File

@ -25,45 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
try try
{ {
// Check common Python installation paths on Linux // Try running python directly first
var candidates = new[] 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.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = fullPath; status.Path = fullPath;
status.Details = $"Found Python {version} at {fullPath}"; status.Details = $"Found Python {version} in PATH";
return status; return status;
} }
}
// Try PATH resolution using 'which' command // Fallback: try 'which' command
if (TryFindInPath("python3", out string pathResult) || if (TryFindInPath("python3", out string pathResult) ||
TryFindInPath("python", out 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.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = fullPath; status.Path = fullPath;
status.Details = $"Found Python {version} in PATH at {fullPath}"; status.Details = $"Found Python {version} in PATH";
return status; return status;
} }
} }
status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.ErrorMessage = "Python not found in PATH";
status.Details = "Checked common installation paths including system, snap, and user-local locations."; status.Details = "Install Python 3.10+ and ensure it's added to PATH.";
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -78,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
return "https://www.python.org/downloads/source/"; 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"; 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 - Arch: sudo pacman -S python python-pip
- Or use pyenv: https://github.com/pyenv/pyenv - 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 - Run: curl -LsSf https://astral.sh/uv/install.sh | sh
- Or download from: https://github.com/astral-sh/uv/releases - 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."; 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) private bool TryValidatePython(string pythonPath, out string version, out string fullPath)
{ {
version = null; version = null;
@ -159,6 +192,65 @@ Note: Make sure ~/.local/bin is in your PATH for user-local installations.";
return false; 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) private bool TryFindInPath(string executable, out string fullPath)
{ {
fullPath = null; fullPath = null;

View File

@ -25,49 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
try try
{ {
// Check common Python installation paths on macOS // Try running python directly first
var candidates = new[] 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.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = fullPath; status.Path = fullPath;
status.Details = $"Found Python {version} at {fullPath}"; status.Details = $"Found Python {version} in PATH";
return status; return status;
} }
}
// Try PATH resolution using 'which' command // Fallback: try 'which' command
if (TryFindInPath("python3", out string pathResult) || if (TryFindInPath("python3", out string pathResult) ||
TryFindInPath("python", out 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.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = fullPath; status.Path = fullPath;
status.Details = $"Found Python {version} in PATH at {fullPath}"; status.Details = $"Found Python {version} in PATH";
return status; return status;
} }
} }
status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.ErrorMessage = "Python not found in PATH";
status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; status.Details = "Install Python 3.10+ and ensure it's added to PATH.";
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -82,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
return "https://www.python.org/downloads/macos/"; 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"; return "https://docs.astral.sh/uv/getting-started/installation/#macos";
} }
@ -95,7 +79,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
- Homebrew: brew install python3 - Homebrew: brew install python3
- Direct download: https://python.org/downloads/macos/ - 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 - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh
- Homebrew: brew install uv - 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."; 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) private bool TryValidatePython(string pythonPath, out string version, out string fullPath)
{ {
version = null; version = null;
@ -160,6 +189,67 @@ Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH.";
return false; 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) private bool TryFindInPath(string executable, out string fullPath)
{ {
fullPath = null; fullPath = null;

View File

@ -1,8 +1,6 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Dependencies.Models;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Dependencies.PlatformDetectors namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
{ {
@ -16,100 +14,54 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
public abstract DependencyStatus DetectPython(); public abstract DependencyStatus DetectPython();
public abstract string GetPythonInstallUrl(); public abstract string GetPythonInstallUrl();
public abstract string GetUVInstallUrl(); public abstract string GetUvInstallUrl();
public abstract string GetInstallationRecommendations(); 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 try
{ {
// Use existing UV detection from ServerInstaller // Try to find uv/uvx in PATH
string uvPath = ServerInstaller.FindUvPath(); if (TryFindUvInPath(out string uvPath, out string version))
if (!string.IsNullOrEmpty(uvPath))
{
if (TryValidateUV(uvPath, out string version))
{ {
status.IsAvailable = true; status.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = uvPath; status.Path = uvPath;
status.Details = $"Found UV {version} at {uvPath}"; status.Details = $"Found uv {version} in PATH";
return status; return status;
} }
}
status.ErrorMessage = "UV package manager not found. Please install UV."; status.ErrorMessage = "uv not found in PATH";
status.Details = "UV is required for managing Python dependencies."; status.Details = "Install uv package manager and ensure it's added to PATH.";
} }
catch (Exception ex) catch (Exception ex)
{ {
status.ErrorMessage = $"Error detecting UV: {ex.Message}"; status.ErrorMessage = $"Error detecting uv: {ex.Message}";
} }
return status; return status;
} }
public virtual DependencyStatus DetectMCPServer() protected bool TryFindUvInPath(out string uvPath, out string version)
{
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))
{
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";
}
}
}
catch (Exception ex)
{
status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}";
}
return status;
}
protected bool TryValidateUV(string uvPath, out string version)
{ {
uvPath = null;
version = null; version = null;
// Try common uv command names
var commands = new[] { "uvx", "uv" };
foreach (var cmd in commands)
{
try try
{ {
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = uvPath, FileName = cmd,
Arguments = "--version", Arguments = "--version",
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardOutput = true,
@ -118,20 +70,22 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
}; };
using var process = Process.Start(psi); using var process = Process.Start(psi);
if (process == null) return false; if (process == null) continue;
string output = process.StandardOutput.ReadToEnd().Trim(); string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(5000); process.WaitForExit(5000);
if (process.ExitCode == 0 && output.StartsWith("uv ")) if (process.ExitCode == 0 && output.StartsWith("uv "))
{ {
version = output.Substring(3); // Remove "uv " prefix version = output.Substring(3).Trim();
uvPath = cmd;
return true; return true;
} }
} }
catch catch
{ {
// Ignore validation errors // Try next command
}
} }
return false; return false;

View File

@ -25,61 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
try try
{ {
// Check common Python installation paths // Try running python directly first (works with Windows App Execution Aliases)
var candidates = new[] 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")
};
foreach (var candidate in candidates)
{
if (TryValidatePython(candidate, out string version, out string fullPath))
{ {
status.IsAvailable = true; status.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = fullPath; status.Path = fullPath;
status.Details = $"Found Python {version} at {fullPath}"; status.Details = $"Found Python {version} in PATH";
return status; return status;
} }
}
// Try PATH resolution using 'where' command // Fallback: try 'where' command
if (TryFindInPath("python.exe", out string pathResult) || if (TryFindInPath("python3.exe", out string pathResult) ||
TryFindInPath("python3.exe", out pathResult)) TryFindInPath("python.exe", out pathResult))
{ {
if (TryValidatePython(pathResult, out string version, out string fullPath)) if (TryValidatePython(pathResult, out version, out fullPath))
{ {
status.IsAvailable = true; status.IsAvailable = true;
status.Version = version; status.Version = version;
status.Path = fullPath; status.Path = fullPath;
status.Details = $"Found Python {version} in PATH at {fullPath}"; status.Details = $"Found Python {version} in PATH";
return status; return status;
} }
} }
status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.ErrorMessage = "Python not found in PATH";
status.Details = "Checked common installation paths and PATH environment variable."; status.Details = "Install Python 3.10+ and ensure it's added to PATH.";
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -94,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; 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"; 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 - Microsoft Store: Search for 'Python 3.10' or higher
- Direct download: https://python.org/downloads/windows/ - 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"" - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex""
- Or download from: https://github.com/astral-sh/uv/releases - Or download from: https://github.com/astral-sh/uv/releases

View File

@ -1,5 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
@ -136,7 +138,45 @@ namespace MCPForUnity.Editor.Helpers
} }
/// <summary> /// <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> /// </summary>
/// <returns>Version string, or "unknown" if not found</returns> /// <returns>Version string, or "unknown" if not found</returns>
public static string GetPackageVersion() public static string GetPackageVersion()

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq; using System.Linq;
using MCPForUnity.External.Tommy; using MCPForUnity.External.Tommy;
using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
@ -14,36 +15,50 @@ namespace MCPForUnity.Editor.Helpers
/// </summary> /// </summary>
public static class CodexConfigHelper public static class CodexConfigHelper
{ {
public static bool IsCodexConfigured(string pythonDir) public static string BuildCodexServerBlock(string uvPath)
{
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)
{ {
var table = new TomlTable(); var table = new TomlTable();
var mcpServers = 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; table["mcp_servers"] = mcpServers;
using var writer = new StringWriter(); using var writer = new StringWriter();
@ -51,7 +66,7 @@ namespace MCPForUnity.Editor.Helpers
return writer.ToString(); 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 // Parse existing TOML or create new root table
var root = TryParseToml(existingToml) ?? new TomlTable(); var root = TryParseToml(existingToml) ?? new TomlTable();
@ -64,7 +79,7 @@ namespace MCPForUnity.Editor.Helpers
var mcpServers = root["mcp_servers"] as TomlTable; var mcpServers = root["mcp_servers"] as TomlTable;
// Create or update unityMCP table // Create or update unityMCP table
mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath);
// Serialize back to TOML // Serialize back to TOML
using var writer = new StringWriter(); 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) 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; command = null;
args = null; args = null;
url = null;
var root = TryParseToml(toml); var root = TryParseToml(toml);
if (root == null) return false; if (root == null) return false;
@ -91,6 +112,15 @@ namespace MCPForUnity.Editor.Helpers
return false; 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"); command = GetTomlString(unity, "command");
args = GetTomlStringArray(unity, "args"); args = GetTomlStringArray(unity, "args");
@ -126,21 +156,38 @@ namespace MCPForUnity.Editor.Helpers
/// <summary> /// <summary>
/// Creates a TomlTable for the unityMCP server configuration /// Creates a TomlTable for the unityMCP server configuration
/// </summary> /// </summary>
/// <param name="uvPath">Path to uv executable</param> /// <param name="uvPath">Path to uv executable (used as fallback if uvx is not available)</param>
/// <param name="serverSrc">Path to server source directory</param> private static TomlTable CreateUnityMcpTable(string uvPath)
private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc)
{ {
var unityMCP = new TomlTable(); var unityMCP = new TomlTable();
unityMCP["command"] = new TomlString { Value = uvPath };
// 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"] = new TomlString { Value = uvxPath };
var argsArray = new TomlArray(); var argsArray = new TomlArray();
argsArray.Add(new TomlString { Value = "run" }); if (!string.IsNullOrEmpty(fromUrl))
argsArray.Add(new TomlString { Value = "--directory" }); {
argsArray.Add(new TomlString { Value = serverSrc }); argsArray.Add(new TomlString { Value = "--from" });
argsArray.Add(new TomlString { Value = "server.py" }); 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; unityMCP["args"] = argsArray;
// Add Windows-specific environment configuration, see: https://github.com/CoplayDev/unity-mcp/issues/315 // Add Windows-specific environment configuration for stdio mode
var platformService = MCPServiceLocator.Platform; var platformService = MCPServiceLocator.Platform;
if (platformService.IsWindows()) if (platformService.IsWindows())
{ {
@ -148,6 +195,7 @@ namespace MCPForUnity.Editor.Helpers
envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
unityMCP["env"] = envTable; unityMCP["env"] = envTable;
} }
}
return unityMCP; return unityMCP;
} }

View File

@ -1,12 +1,16 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
public static class ConfigJsonBuilder 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(); var root = new JObject();
bool isVSCode = client?.mcpType == McpTypes.VSCode; bool isVSCode = client?.mcpType == McpTypes.VSCode;
@ -21,20 +25,20 @@ namespace MCPForUnity.Editor.Helpers
} }
var unity = new JObject(); var unity = new JObject();
PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); PopulateUnityNode(unity, uvPath, client, isVSCode);
container["unityMCP"] = unity; container["unityMCP"] = unity;
return root.ToString(Formatting.Indented); 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(); if (root == null) root = new JObject();
bool isVSCode = client?.mcpType == McpTypes.VSCode; bool isVSCode = client?.mcpType == McpTypes.VSCode;
JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers");
JObject unity = container["unityMCP"] as JObject ?? new JObject(); JObject unity = container["unityMCP"] as JObject ?? new JObject();
PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); PopulateUnityNode(unity, uvPath, client, isVSCode);
container["unityMCP"] = unity; container["unityMCP"] = unity;
return root; return root;
@ -42,81 +46,95 @@ namespace MCPForUnity.Editor.Helpers
/// <summary> /// <summary>
/// Centralized builder that applies all caveats consistently. /// Centralized builder that applies all caveats consistently.
/// - Sets command/args with provided directory /// - Sets command/args with uvx and package version
/// - Ensures env exists /// - Ensures env exists
/// - Adds type:"stdio" for VSCode /// - Adds transport configuration (HTTP or stdio)
/// - Adds disabled:false for Windsurf/Kiro only when missing /// - Adds disabled:false for Windsurf/Kiro only when missing
/// </summary> /// </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 if (useHttpTransport)
string effectiveDir = directory;
#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX
bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode);
if (isCursor && !string.IsNullOrEmpty(directory))
{ {
// Replace canonical path segment with the symlink path if present // HTTP mode: Use URL, no command
const string canonical = "/Library/Application Support/"; string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
const string symlinkSeg = "/Library/AppSupport/"; string httpProperty = isWindsurf ? "serverUrl" : "url";
try 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 unity.Remove(staleProperty);
if (directory.Contains(canonical)) }
// 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)
{ {
var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); unity["type"] = "http";
if (System.IO.Directory.Exists(candidate))
{
effectiveDir = candidate;
} }
} }
else else
{ {
// If installer returned XDG-style on macOS, map to canonical symlink // Stdio mode: Use uvx command
string norm = directory.Replace('\\', '/'); var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
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;
}
}
}
}
catch { /* fallback to original directory on any error */ }
}
#endif
unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); 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) if (isVSCode)
{ {
unity["type"] = "stdio"; unity["type"] = "stdio";
} }
else
{
// Remove type if it somehow exists from previous clients
if (unity["type"] != null) unity.Remove("type");
} }
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) if (unity["env"] == null)
{ {
unity["env"] = new JObject(); unity["env"] = new JObject();
} }
}
else if (isWindsurf && unity["env"] != null)
{
unity.Remove("env");
}
if (unity["disabled"] == null) if (requiresDisabled && unity["disabled"] == null)
{ {
unity["disabled"] = false; unity["disabled"] = false;
} }
} }
}
private static JObject EnsureObject(JObject parent, string name) private static JObject EnsureObject(JObject parent, string name)
{ {

View File

@ -5,12 +5,13 @@ using System.Linq;
using System.Text; using System.Text;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using UnityEditor; using UnityEditor;
using MCPForUnity.Editor.Constants;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
internal static class ExecPath 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. // Resolve Claude CLI absolute path. Pref → env → common locations → PATH.
internal static string ResolveClaude() internal static string ResolveClaude()
@ -157,12 +158,6 @@ namespace MCPForUnity.Editor.Helpers
catch { } catch { }
} }
// Use existing UV resolver; returns absolute path or null.
internal static string ResolveUv()
{
return ServerInstaller.FindUvPath();
}
internal static bool TryRun( internal static bool TryRun(
string file, string file,
string args, string args,

View File

@ -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}";
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 1ad9865b38bcc4efe85d4970c6d3a997 guid: 2051d90316ea345c09240c80c7138e3b
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -3,13 +3,15 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; 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;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Dependencies;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
@ -19,13 +21,13 @@ namespace MCPForUnity.Editor.Helpers
/// </summary> /// </summary>
public static class McpConfigurationHelper public static class McpConfigurationHelper
{ {
private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig;
/// <summary> /// <summary>
/// Writes MCP configuration to the specified path using sophisticated logic /// Writes MCP configuration to the specified path using sophisticated logic
/// that preserves existing configuration and only writes when necessary /// that preserves existing configuration and only writes when necessary
/// </summary> /// </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) // 0) Respect explicit lock (hidden pref or UI toggle)
try try
@ -94,19 +96,8 @@ namespace MCPForUnity.Editor.Helpers
catch { } catch { }
// 1) Start from existing, only fill gaps (prefer trusted resolver) // 1) Start from existing, only fill gaps (prefer trusted resolver)
string uvPath = ServerInstaller.FindUvPath(); string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
// Optionally trust existingCommand if it looks like uv/uv.exe if (uvxPath == null) return "uv package manager not found. Please install uv first.";
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);
// Ensure containers exist and write back configuration // Ensure containers exist and write back configuration
JObject existingRoot; JObject existingRoot;
@ -115,27 +106,20 @@ namespace MCPForUnity.Editor.Helpers
else else
existingRoot = JObject.FromObject(existingConfig); existingRoot = JObject.FromObject(existingConfig);
existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient);
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
EnsureConfigDirectoryExists(configPath); EnsureConfigDirectoryExists(configPath);
WriteAtomicFile(configPath, mergedJson); WriteAtomicFile(configPath, mergedJson);
try
{
if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
}
catch { }
return "Configured successfully"; return "Configured successfully";
} }
/// <summary> /// <summary>
/// Configures a Codex client with sophisticated TOML handling /// Configures a Codex client with sophisticated TOML handling
/// </summary> /// </summary>
public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) public static string ConfigureCodexClient(string configPath, McpClient mcpClient)
{ {
try try
{ {
@ -165,66 +149,20 @@ namespace MCPForUnity.Editor.Helpers
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
} }
string uvPath = ServerInstaller.FindUvPath(); string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
try if (uvxPath == null)
{ {
var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); return "uv package manager not found. Please install uv first.";
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 updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath);
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath, serverSrc);
EnsureConfigDirectoryExists(configPath); EnsureConfigDirectoryExists(configPath);
WriteAtomicFile(configPath, updatedToml); WriteAtomicFile(configPath, updatedToml);
try
{
if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
}
catch { }
return "Configured successfully"; 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> /// <summary>
/// Gets the appropriate config file path for the given MCP client based on OS /// Gets the appropriate config file path for the given MCP client based on OS
/// </summary> /// </summary>
@ -258,12 +196,12 @@ namespace MCPForUnity.Editor.Helpers
Directory.CreateDirectory(Path.GetDirectoryName(configPath)); Directory.CreateDirectory(Path.GetDirectoryName(configPath));
} }
public static string ExtractDirectoryArg(string[] args) public static string ExtractUvxUrl(string[] args)
{ {
if (args == null) return null; if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++) 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]; 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) public static void WriteAtomicFile(string path, string contents)
{ {
string tmp = path + ".tmp"; string tmp = path + ".tmp";
@ -393,39 +279,5 @@ namespace MCPForUnity.Editor.Helpers
try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } 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();
}
}
} }
} }

View File

@ -0,0 +1,62 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility for persisting tool state across domain reloads. State is stored in
/// Library so it stays local to the project and is cleared by Unity as needed.
/// </summary>
public static class McpJobStateStore
{
private static string GetStatePath(string toolName)
{
if (string.IsNullOrEmpty(toolName))
{
throw new ArgumentException("toolName cannot be null or empty", nameof(toolName));
}
var libraryPath = Path.Combine(Application.dataPath, "..", "Library");
var fileName = $"McpState_{toolName}.json";
return Path.GetFullPath(Path.Combine(libraryPath, fileName));
}
public static void SaveState<T>(string toolName, T state)
{
var path = GetStatePath(toolName);
Directory.CreateDirectory(Path.GetDirectoryName(path));
var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance<T>());
File.WriteAllText(path, json);
}
public static T LoadState<T>(string toolName)
{
var path = GetStatePath(toolName);
if (!File.Exists(path))
{
return default;
}
try
{
var json = File.ReadAllText(path);
return JsonConvert.DeserializeObject<T>(json);
}
catch (Exception)
{
return default;
}
}
public static void ClearState(string toolName)
{
var path = GetStatePath(toolName);
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 4bdcf382960c842aab0a08c90411ab43 guid: 28912085dd68342f8a9fda8a43c83a59
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,33 +1,53 @@
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Constants;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
internal static class McpLog 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 WarnPrefix = "<b><color=#cc7a00>MCP-FOR-UNITY</color></b>:";
private const string ErrorPrefix = "<b><color=#cc3333>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) public static void Info(string message, bool always = true)
{ {
if (!always && !IsDebugEnabled()) return; if (!always && !IsDebugEnabled()) return;
Debug.Log($"{LogPrefix} {message}"); UnityEngine.Debug.Log($"{InfoPrefix} {message}");
} }
public static void Warn(string message) public static void Warn(string message)
{ {
Debug.LogWarning($"{WarnPrefix} {message}"); UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}");
} }
public static void Error(string message) public static void Error(string message)
{ {
Debug.LogError($"{ErrorPrefix} {message}"); UnityEngine.Debug.LogError($"{ErrorPrefix} {message}");
} }
} }
} }

View File

@ -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;
}
}
}

View File

@ -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 { }
}
}
}

View File

@ -8,6 +8,7 @@ using System.Text;
using System.Threading; using System.Threading;
using Newtonsoft.Json; using Newtonsoft.Json;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Constants;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
@ -18,7 +19,7 @@ namespace MCPForUnity.Editor.Helpers
{ {
private static bool IsDebugEnabled() private static bool IsDebugEnabled()
{ {
try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); }
catch { return false; } catch { return false; }
} }
@ -35,42 +36,20 @@ namespace MCPForUnity.Editor.Helpers
} }
/// <summary> /// <summary>
/// Get the port to use - either from storage or discover a new one /// Get the port to use from storage, or return the default if none has been saved yet.
/// Will try stored port first, then fallback to discovering new port
/// </summary> /// </summary>
/// <returns>Port number to use</returns> /// <returns>Port number to use</returns>
public static int GetPortWithFallback() public static int GetPortWithFallback()
{ {
// Try to load stored port first, but only if it's from the current project
var storedConfig = GetStoredPortConfig(); var storedConfig = GetStoredPortConfig();
if (storedConfig != null && if (storedConfig != null &&
storedConfig.unity_port > 0 && storedConfig.unity_port > 0 &&
string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase))
IsPortAvailable(storedConfig.unity_port))
{ {
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; return storedConfig.unity_port;
} }
// If stored port exists but is currently busy, wait briefly for release return DefaultPort;
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;
} }
/// <summary> /// <summary>
@ -81,10 +60,30 @@ namespace MCPForUnity.Editor.Helpers
{ {
int newPort = FindAvailablePort(); int newPort = FindAvailablePort();
SavePort(newPort); 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; 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> /// <summary>
/// Find an available port starting from the default port /// Find an available port starting from the default port
/// </summary> /// </summary>
@ -94,18 +93,18 @@ namespace MCPForUnity.Editor.Helpers
// Always try default port first // Always try default port first
if (IsPortAvailable(DefaultPort)) 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; 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 // Search for alternatives
for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++)
{ {
if (IsPortAvailable(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; return port;
} }
} }
@ -214,11 +213,11 @@ namespace MCPForUnity.Editor.Helpers
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); 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) 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) 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; return 0;
} }
} }
@ -281,7 +280,7 @@ namespace MCPForUnity.Editor.Helpers
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogWarning($"Could not load port config: {ex.Message}"); McpLog.Warn($"Could not load port config: {ex.Message}");
return null; return null;
} }
} }

View File

@ -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";
}
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 2c76f0c7ff138ba4a952481e04bc3974 guid: 936e878ce1275453bae5e0cf03bd9d30
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -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;
}
}
}

View File

@ -1,62 +1,108 @@
using System; using Newtonsoft.Json;
using System.Collections.Generic;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
/// <summary> public interface IMcpResponse
/// 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> [JsonProperty("success")]
/// Creates a standardized success response object. bool Success { get; }
/// </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
public sealed class SuccessResponse : IMcpResponse
{ {
return new { success = true, message = message }; [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)
{
Message = message;
Data = data;
} }
} }
/// <summary> public sealed class ErrorResponse : IMcpResponse
/// 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)
{ {
if (data != null) [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)
{ {
// Note: The key is "error" for error messages, not "message" Code = messageOrCode;
return new Error = messageOrCode;
Data = data;
}
}
public sealed class PendingResponse : IMcpResponse
{ {
success = false, [JsonProperty("success")]
// Preserve original behavior while adding a machine-parsable code field. public bool Success => true;
// If callers pass a code string, it will be echoed in both code and error.
code = errorCodeOrMessage, [JsonIgnore]
error = errorCodeOrMessage, public bool success => Success; // Backward-compatible casing for reflection-based tests
data = data,
}; [JsonProperty("_mcp_status")]
} public string Status => "pending";
else
[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)
{ {
return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; Message = string.IsNullOrEmpty(message) ? null : message;
} PollIntervalSeconds = pollIntervalSeconds;
Data = data;
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5862c6a6d0a914f4d83224f8d039cf7b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -2,6 +2,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Helpers namespace MCPForUnity.Editor.Helpers
{ {
@ -11,8 +13,8 @@ namespace MCPForUnity.Editor.Helpers
/// </summary> /// </summary>
public static class TelemetryHelper public static class TelemetryHelper
{ {
private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; private const string TELEMETRY_DISABLED_KEY = EditorPrefKeys.TelemetryDisabled;
private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; private const string CUSTOMER_UUID_KEY = EditorPrefKeys.CustomerUuid;
private static Action<Dictionary<string, object>> s_sender; private static Action<Dictionary<string, object>> s_sender;
/// <summary> /// <summary>
@ -140,8 +142,8 @@ namespace MCPForUnity.Editor.Helpers
{ {
RecordEvent("bridge_startup", new Dictionary<string, object> RecordEvent("bridge_startup", new Dictionary<string, object>
{ {
["bridge_version"] = "3.0.2", ["bridge_version"] = AssetPathUtility.GetPackageVersion(),
["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() ["auto_connect"] = StdioBridgeHost.IsAutoConnectMode()
}); });
} }
@ -213,7 +215,7 @@ namespace MCPForUnity.Editor.Helpers
{ {
try try
{ {
return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); return UnityEditor.EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
} }
catch catch
{ {

View File

@ -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);
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: d68ef794590944f1ea7ee102c91887c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -9,9 +9,10 @@
"Editor" "Editor"
], ],
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false, "overrideReferences": false,
"precompiledReferences": [], "precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": true, "autoReferenced": true,
"defineConstraints": [], "defineConstraints": [],
"versionDefines": [], "versionDefines": [],

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 96dc847eb7f7a45e0b91241db934a4be
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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();
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 02a6714b521ec47868512a8db433975c guid: 9e7f37616736f4d3cbd8bdbc626f5ab9
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@ -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();
}
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 0c392d9059b864f608a4d32e4347c3d6 guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@ -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}");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4436b2149abf4b0d8014f81cd29a2bd0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f1d589c8c8684e6f919ffb393c4b4db5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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) catch (Exception e)
{ {
return Response.Error($"Error getting active tool: {e.Message}"); return new ErrorResponse($"Error getting active tool: {e.Message}");
} }
} }
} }

View File

@ -29,11 +29,11 @@ namespace MCPForUnity.Editor.Resources.Editor
activeObjectName = UnityEditor.Selection.activeObject?.name activeObjectName = UnityEditor.Selection.activeObject?.name
}; };
return Response.Success("Retrieved editor state.", state); return new SuccessResponse("Retrieved editor state.", state);
} }
catch (Exception e) catch (Exception e)
{ {
return Response.Error($"Error getting editor state: {e.Message}"); return new ErrorResponse($"Error getting editor state: {e.Message}");
} }
} }
} }

View File

@ -19,7 +19,7 @@ namespace MCPForUnity.Editor.Resources.Editor
if (stage == null) 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 var stageInfo = new
@ -31,11 +31,11 @@ namespace MCPForUnity.Editor.Resources.Editor
isDirty = stage.scene.isDirty isDirty = stage.scene.isDirty
}; };
return Response.Success("Prefab stage info retrieved.", stageInfo); return new SuccessResponse("Prefab stage info retrieved.", stageInfo);
} }
catch (Exception e) catch (Exception e)
{ {
return Response.Error($"Error getting prefab stage info: {e.Message}"); return new ErrorResponse($"Error getting prefab stage info: {e.Message}");
} }
} }
} }

View File

@ -41,11 +41,11 @@ namespace MCPForUnity.Editor.Resources.Editor
assetGUIDs = UnityEditor.Selection.assetGUIDs assetGUIDs = UnityEditor.Selection.assetGUIDs
}; };
return Response.Success("Retrieved current selection details.", selectionInfo); return new SuccessResponse("Retrieved current selection details.", selectionInfo);
} }
catch (Exception e) catch (Exception e)
{ {
return Response.Error($"Error getting selection: {e.Message}"); return new ErrorResponse($"Error getting selection: {e.Message}");
} }
} }
} }

View File

@ -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) catch (Exception e)
{ {
return Response.Error($"Error getting editor windows: {e.Message}"); return new ErrorResponse($"Error getting editor windows: {e.Message}");
} }
} }
} }

View File

@ -33,7 +33,7 @@ namespace MCPForUnity.Editor.Resources.MenuItems
} }
string message = $"Retrieved {items.Count} menu items"; string message = $"Retrieved {items.Count} menu items";
return Response.Success(message, items); return new SuccessResponse(message, items);
} }
internal static List<string> GetMenuItemsInternal(bool forceRefresh) internal static List<string> GetMenuItemsInternal(bool forceRefresh)

View File

@ -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) catch (Exception e)
{ {
return Response.Error($"Failed to retrieve layers: {e.Message}"); return new ErrorResponse($"Failed to retrieve layers: {e.Message}");
} }
} }
} }

View File

@ -30,11 +30,11 @@ namespace MCPForUnity.Editor.Resources.Project
assetsPath = assetsPath assetsPath = assetsPath
}; };
return Response.Success("Retrieved project info.", info); return new SuccessResponse("Retrieved project info.", info);
} }
catch (Exception e) catch (Exception e)
{ {
return Response.Error($"Error getting project info: {e.Message}"); return new ErrorResponse($"Error getting project info: {e.Message}");
} }
} }
} }

View File

@ -16,11 +16,11 @@ namespace MCPForUnity.Editor.Resources.Project
try try
{ {
string[] tags = InternalEditorUtility.tags; string[] tags = InternalEditorUtility.tags;
return Response.Success("Retrieved current tags.", tags); return new SuccessResponse("Retrieved current tags.", tags);
} }
catch (Exception e) catch (Exception e)
{ {
return Response.Error($"Failed to retrieve tags: {e.Message}"); return new ErrorResponse($"Failed to retrieve tags: {e.Message}");
} }
} }
} }

View File

@ -27,12 +27,12 @@ namespace MCPForUnity.Editor.Resources.Tests
catch (Exception ex) catch (Exception ex)
{ {
McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); 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"; 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(); string modeStr = @params["mode"]?.ToString();
if (string.IsNullOrEmpty(modeStr)) 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)) 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}"); McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}");
@ -66,11 +66,11 @@ namespace MCPForUnity.Editor.Resources.Tests
catch (Exception ex) catch (Exception ex)
{ {
McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); 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"; string message = $"Retrieved {result.Count} {parsedMode.Value} tests";
return Response.Success(message, result); return new SuccessResponse(message, result);
} }
} }

View File

@ -1,174 +1,130 @@
using System; using System;
using System.IO; using System.Threading.Tasks;
using System.Net; using UnityEditor;
using System.Net.Sockets; using MCPForUnity.Editor.Constants;
using System.Text; using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Services namespace MCPForUnity.Editor.Services
{ {
/// <summary> /// <summary>
/// Implementation of bridge control service /// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio).
/// </summary> /// </summary>
public class BridgeControlService : IBridgeControlService public class BridgeControlService : IBridgeControlService
{ {
public bool IsRunning => MCPForUnityBridge.IsRunning; private readonly TransportManager _transportManager;
public int CurrentPort => MCPForUnityBridge.GetCurrentPort(); private TransportMode _preferredMode = TransportMode.Http;
public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode();
public void Start() public BridgeControlService()
{ {
// If server is installed, use auto-connect mode _transportManager = MCPServiceLocator.TransportManager;
// Otherwise use standard mode
string serverPath = MCPServiceLocator.Paths.GetMcpServerPath();
if (!string.IsNullOrEmpty(serverPath) && File.Exists(Path.Combine(serverPath, "server.py")))
{
MCPForUnityBridge.StartAutoConnect();
} }
else
private TransportMode ResolvePreferredMode()
{ {
MCPForUnityBridge.Start(); 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
{
Success = pingSucceeded && handshakeValid,
HandshakeValid = handshakeValid,
PingSucceeded = pingSucceeded,
Message = message
};
}
public bool IsRunning => _transportManager.GetState().IsConnected;
public int CurrentPort
{
get
{
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) public BridgeVerificationResult Verify(int port)
{ {
var result = new BridgeVerificationResult var mode = _transportManager.ActiveMode ?? ResolvePreferredMode();
{ bool pingSucceeded = _transportManager.VerifyAsync().GetAwaiter().GetResult();
Success = false, var state = _transportManager.GetState();
HandshakeValid = false,
PingSucceeded = false,
Message = "Verification not started"
};
const int ConnectTimeoutMs = 1000; if (mode == TransportMode.Stdio)
const int FrameTimeoutMs = 30000; // Match bridge frame I/O timeout
try
{ {
using (var client = new TcpClient()) bool handshakeValid = state.IsConnected && port == CurrentPort;
{ string message = handshakeValid
// Attempt connection ? $"STDIO transport listening on port {CurrentPort}"
var connectTask = client.ConnectAsync(IPAddress.Loopback, port); : $"STDIO transport port mismatch (expected {CurrentPort}, got {port})";
if (!connectTask.Wait(ConnectTimeoutMs)) return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid);
{
result.Message = "Connection timeout";
return result;
} }
using (var stream = client.GetStream()) return BuildVerificationResult(state, mode, pingSucceeded);
{
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}";
}
return result;
}
// 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());
}
}
} }
} }

View File

@ -2,10 +2,12 @@ using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Data;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
@ -20,38 +22,24 @@ namespace MCPForUnity.Editor.Services
public void ConfigureClient(McpClient client) public void ConfigureClient(McpClient client)
{ {
try var pathService = MCPServiceLocator.Paths;
{ string uvxPath = pathService.GetUvxPath();
string configPath = McpConfigurationHelper.GetClientConfigPath(client); string configPath = McpConfigurationHelper.GetClientConfigPath(client);
McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); 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 string result = client.mcpType == McpTypes.Codex
? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) ? McpConfigurationHelper.ConfigureCodexClient(configPath, client)
: McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); : McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
if (result == "Configured successfully") if (result == "Configured successfully")
{ {
client.SetStatus(McpStatus.Configured); client.SetStatus(McpStatus.Configured);
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: {client.name} configured successfully");
} }
else else
{ {
Debug.LogWarning($"Configuration completed with message: {result}"); client.SetStatus(McpStatus.NotConfigured);
} throw new InvalidOperationException($"Configuration failed: {result}");
CheckClientStatus(client);
}
catch (Exception ex)
{
Debug.LogError($"Failed to configure {client.name}: {ex.Message}");
throw;
} }
} }
@ -64,14 +52,8 @@ namespace MCPForUnity.Editor.Services
{ {
try try
{ {
// Skip if already configured // Always re-run configuration so core fields stay current
CheckClientStatus(client, attemptAutoRewrite: false); 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 // Check if required tools are available
if (client.mcpType == McpTypes.ClaudeCode) if (client.mcpType == McpTypes.ClaudeCode)
@ -83,20 +65,14 @@ namespace MCPForUnity.Editor.Services
continue; continue;
} }
// Force a fresh registration so transport settings stay current
UnregisterClaudeCode();
RegisterClaudeCode(); RegisterClaudeCode();
summary.SuccessCount++; summary.SuccessCount++;
summary.Messages.Add($"✓ {client.name}: Registered successfully"); summary.Messages.Add($"✓ {client.name}: Re-registered successfully");
} }
else else
{ {
// Other clients require UV
if (!pathService.IsUvDetected())
{
summary.SkippedCount++;
summary.Messages.Add($"➜ {client.name}: UV not found");
continue;
}
ConfigureClient(client); ConfigureClient(client);
summary.SuccessCount++; summary.SuccessCount++;
summary.Messages.Add($"✓ {client.name}: Configured successfully"); summary.Messages.Add($"✓ {client.name}: Configured successfully");
@ -134,32 +110,45 @@ namespace MCPForUnity.Editor.Services
} }
string configJson = File.ReadAllText(configPath); string configJson = File.ReadAllText(configPath);
string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath();
// Check configuration based on client type // Check configuration based on client type
string[] args = null; string[] args = null;
string configuredUrl = null;
bool configExists = false; bool configExists = false;
switch (client.mcpType) switch (client.mcpType)
{ {
case McpTypes.VSCode: case McpTypes.VSCode:
dynamic vsConfig = JsonConvert.DeserializeObject(configJson); var vsConfig = JsonConvert.DeserializeObject<JToken>(configJson) as JObject;
if (vsConfig?.servers?.unityMCP != null) if (vsConfig != null)
{
var unityToken =
vsConfig["servers"]?["unityMCP"]
?? vsConfig["mcp"]?["servers"]?["unityMCP"];
if (unityToken is JObject unityObj)
{ {
args = vsConfig.servers.unityMCP.args.ToObject<string[]>();
configExists = true; configExists = true;
var argsToken = unityObj["args"];
if (argsToken is JArray)
{
args = argsToken.ToObject<string[]>();
} }
else if (vsConfig?.mcp?.servers?.unityMCP != null)
var urlToken = unityObj["url"] ?? unityObj["serverUrl"];
if (urlToken != null && urlToken.Type != JTokenType.Null)
{ {
args = vsConfig.mcp.servers.unityMCP.args.ToObject<string[]>(); configuredUrl = urlToken.ToString();
configExists = true; }
}
} }
break; break;
case McpTypes.Codex: case McpTypes.Codex:
if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs, out var codexUrl))
{ {
args = codexArgs; args = codexArgs;
configuredUrl = codexUrl;
configExists = true; configExists = true;
} }
break; break;
@ -176,9 +165,20 @@ namespace MCPForUnity.Editor.Services
if (configExists) if (configExists)
{ {
string configuredDir = McpConfigurationHelper.ExtractDirectoryArg(args); bool matches = false;
bool matches = !string.IsNullOrEmpty(configuredDir) &&
McpConfigurationHelper.PathsEqual(configuredDir, pythonDir); 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) if (matches)
{ {
@ -190,15 +190,18 @@ namespace MCPForUnity.Editor.Services
try try
{ {
string rewriteResult = client.mcpType == McpTypes.Codex string rewriteResult = client.mcpType == McpTypes.Codex
? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) ? McpConfigurationHelper.ConfigureCodexClient(configPath, client)
: McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); : McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
if (rewriteResult == "Configured successfully") if (rewriteResult == "Configured successfully")
{ {
bool debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); bool debugLogsEnabled = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
if (debugLogsEnabled) 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); client.SetStatus(McpStatus.Configured);
} }
@ -233,21 +236,29 @@ namespace MCPForUnity.Editor.Services
public void RegisterClaudeCode() public void RegisterClaudeCode()
{ {
var pathService = MCPServiceLocator.Paths; 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(); string claudePath = pathService.GetClaudeCliPath();
if (string.IsNullOrEmpty(claudePath)) if (string.IsNullOrEmpty(claudePath))
{ {
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
} }
string uvPath = pathService.GetUvPath() ?? "uv"; // Check transport preference
string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; 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 projectDir = Path.GetDirectoryName(Application.dataPath);
string pathPrepend = null; string pathPrepend = null;
@ -278,7 +289,7 @@ namespace MCPForUnity.Editor.Services
string combined = ($"{stdout}\n{stderr}") ?? string.Empty; string combined = ($"{stdout}\n{stderr}") ?? string.Empty;
if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) 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 else
{ {
@ -287,7 +298,7 @@ namespace MCPForUnity.Editor.Services
return; 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 // Update status
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
@ -323,14 +334,14 @@ namespace MCPForUnity.Editor.Services
{ {
claudeClient.SetStatus(McpStatus.NotConfigured); 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; return;
} }
// Remove the server // Remove the server
if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) 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 else
{ {
@ -366,19 +377,32 @@ namespace MCPForUnity.Editor.Services
public string GenerateConfigJson(McpClient client) public string GenerateConfigJson(McpClient client)
{ {
string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string uvPath = MCPServiceLocator.Paths.GetUvPath();
// Claude Code uses CLI commands, not JSON config // Claude Code uses CLI commands, not JSON config
if (client.mcpType == McpTypes.ClaudeCode) if (client.mcpType == McpTypes.ClaudeCode)
{ {
if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) // Check transport preference
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
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"; return "# Error: Configuration not available - check paths in Advanced Settings";
} }
// Show the actual command that RegisterClaudeCode() uses string gitUrl = AssetPathUtility.GetMcpServerGitUrl();
string registerCommand = $"claude mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; registerCommand = $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity";
}
return "# Register the MCP server with Claude Code:\n" + return "# Register the MCP server with Claude Code:\n" +
$"{registerCommand}\n\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"; "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\" }"; return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }";
try try
{ {
if (client.mcpType == McpTypes.Codex) if (client.mcpType == McpTypes.Codex)
{ {
return CodexConfigHelper.BuildCodexServerBlock(uvPath, return CodexConfigHelper.BuildCodexServerBlock(uvxPath);
McpConfigurationHelper.ResolveServerDirectory(pythonDir, null));
} }
else else
{ {
return ConfigJsonBuilder.BuildManualConfigJson(uvPath, pythonDir, client); return ConfigJsonBuilder.BuildManualConfigJson(uvxPath, client);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -479,22 +502,46 @@ namespace MCPForUnity.Editor.Services
{ {
try 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; return;
} }
string configJson = File.ReadAllText(configPath); // Use 'claude mcp list' to check if UnityMCP is registered
dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); 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; pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
// Only check for UnityMCP (fixed - removed candidate hacks) }
if (servers.UnityMCP != null) 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); client.SetStatus(McpStatus.Configured);
return; return;
@ -508,5 +555,28 @@ namespace MCPForUnity.Editor.Services
client.SetStatus(McpStatus.Error, ex.Message); 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);
}
} }
} }

View File

@ -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}");
}
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4c0cf970a7b494a659be151dc0124296
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,3 +1,6 @@
using System.Threading.Tasks;
using MCPForUnity.Editor.Services.Transport;
namespace MCPForUnity.Editor.Services namespace MCPForUnity.Editor.Services
{ {
/// <summary> /// <summary>
@ -21,14 +24,20 @@ namespace MCPForUnity.Editor.Services
bool IsAutoConnectMode { get; } bool IsAutoConnectMode { get; }
/// <summary> /// <summary>
/// Starts the MCP for Unity Bridge /// Gets the currently active transport mode, if any
/// </summary> /// </summary>
void Start(); TransportMode? ActiveMode { get; }
/// <summary> /// <summary>
/// Stops the MCP for Unity Bridge /// Starts the MCP for Unity Bridge asynchronously
/// </summary> /// </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> /// <summary>
/// Verifies the bridge connection by sending a ping and waiting for a pong response /// 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> /// <param name="port">The port to verify</param>
/// <returns>Verification result with detailed status</returns> /// <returns>Verification result with detailed status</returns>
BridgeVerificationResult Verify(int port); 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> /// <summary>

View File

@ -6,16 +6,10 @@ namespace MCPForUnity.Editor.Services
public interface IPathResolverService public interface IPathResolverService
{ {
/// <summary> /// <summary>
/// Gets the MCP server path (respects override if set) /// Gets the uvx package manager path (respects override if set)
/// </summary> /// </summary>
/// <returns>Path to the MCP server directory containing server.py, or null if not found</returns> /// <returns>Path to the uvx executable, or null if not found</returns>
string GetMcpServerPath(); string GetUvxPath();
/// <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();
/// <summary> /// <summary>
/// Gets the Claude CLI path (respects override if set) /// Gets the Claude CLI path (respects override if set)
@ -29,12 +23,6 @@ namespace MCPForUnity.Editor.Services
/// <returns>True if Python is found</returns> /// <returns>True if Python is found</returns>
bool IsPythonDetected(); bool IsPythonDetected();
/// <summary>
/// Checks if UV is detected on the system
/// </summary>
/// <returns>True if UV is found</returns>
bool IsUvDetected();
/// <summary> /// <summary>
/// Checks if Claude CLI is detected on the system /// Checks if Claude CLI is detected on the system
/// </summary> /// </summary>
@ -42,16 +30,10 @@ namespace MCPForUnity.Editor.Services
bool IsClaudeCliDetected(); bool IsClaudeCliDetected();
/// <summary> /// <summary>
/// Sets an override for the MCP server path /// Sets an override for the uvx path
/// </summary> /// </summary>
/// <param name="path">Path to override with</param> /// <param name="path">Path to override with</param>
void SetMcpServerOverride(string path); void SetUvxPathOverride(string path);
/// <summary>
/// Sets an override for the UV path
/// </summary>
/// <param name="path">Path to override with</param>
void SetUvPathOverride(string path);
/// <summary> /// <summary>
/// Sets an override for the Claude CLI path /// Sets an override for the Claude CLI path
@ -60,14 +42,9 @@ namespace MCPForUnity.Editor.Services
void SetClaudeCliPathOverride(string path); void SetClaudeCliPathOverride(string path);
/// <summary> /// <summary>
/// Clears the MCP server path override /// Clears the uvx path override
/// </summary> /// </summary>
void ClearMcpServerOverride(); void ClearUvxPathOverride();
/// <summary>
/// Clears the UV path override
/// </summary>
void ClearUvPathOverride();
/// <summary> /// <summary>
/// Clears the Claude CLI path override /// Clears the Claude CLI path override
@ -75,14 +52,9 @@ namespace MCPForUnity.Editor.Services
void ClearClaudeCliPathOverride(); void ClearClaudeCliPathOverride();
/// <summary> /// <summary>
/// Gets whether a MCP server path override is active /// Gets whether a uvx path override is active
/// </summary> /// </summary>
bool HasMcpServerOverride { get; } bool HasUvxPathOverride { get; }
/// <summary>
/// Gets whether a UV path override is active
/// </summary>
bool HasUvPathOverride { get; }
/// <summary> /// <summary>
/// Gets whether a Claude CLI path override is active /// Gets whether a Claude CLI path override is active

View File

@ -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);
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: a2487319df5cc47baa2c635b911038c5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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();
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d41bfc9780b774affa6afbffd081eb79
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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();
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 497592a93fd994b2cb9803e7c8636ff7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: b9627dbaa92d24783a9f20e42efcea18
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,4 +1,7 @@
using System; using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Services namespace MCPForUnity.Editor.Services
{ {
@ -10,20 +13,22 @@ namespace MCPForUnity.Editor.Services
private static IBridgeControlService _bridgeService; private static IBridgeControlService _bridgeService;
private static IClientConfigurationService _clientService; private static IClientConfigurationService _clientService;
private static IPathResolverService _pathService; private static IPathResolverService _pathService;
private static IPythonToolRegistryService _pythonToolRegistryService;
private static ITestRunnerService _testRunnerService; private static ITestRunnerService _testRunnerService;
private static IToolSyncService _toolSyncService;
private static IPackageUpdateService _packageUpdateService; private static IPackageUpdateService _packageUpdateService;
private static IPlatformService _platformService; 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 IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
public static IPathResolverService Paths => _pathService ??= new PathResolverService(); public static IPathResolverService Paths => _pathService ??= new PathResolverService();
public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
public static IPlatformService Platform => _platformService ??= new PlatformService(); 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> /// <summary>
/// Registers a custom implementation for a service (useful for testing) /// Registers a custom implementation for a service (useful for testing)
@ -38,16 +43,18 @@ namespace MCPForUnity.Editor.Services
_clientService = c; _clientService = c;
else if (implementation is IPathResolverService p) else if (implementation is IPathResolverService p)
_pathService = p; _pathService = p;
else if (implementation is IPythonToolRegistryService ptr)
_pythonToolRegistryService = ptr;
else if (implementation is ITestRunnerService t) else if (implementation is ITestRunnerService t)
_testRunnerService = t; _testRunnerService = t;
else if (implementation is IToolSyncService ts)
_toolSyncService = ts;
else if (implementation is IPackageUpdateService pu) else if (implementation is IPackageUpdateService pu)
_packageUpdateService = pu; _packageUpdateService = pu;
else if (implementation is IPlatformService ps) else if (implementation is IPlatformService ps)
_platformService = 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> /// <summary>
@ -58,20 +65,22 @@ namespace MCPForUnity.Editor.Services
(_bridgeService as IDisposable)?.Dispose(); (_bridgeService as IDisposable)?.Dispose();
(_clientService as IDisposable)?.Dispose(); (_clientService as IDisposable)?.Dispose();
(_pathService as IDisposable)?.Dispose(); (_pathService as IDisposable)?.Dispose();
(_pythonToolRegistryService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose(); (_testRunnerService as IDisposable)?.Dispose();
(_toolSyncService as IDisposable)?.Dispose();
(_packageUpdateService as IDisposable)?.Dispose(); (_packageUpdateService as IDisposable)?.Dispose();
(_platformService as IDisposable)?.Dispose(); (_platformService as IDisposable)?.Dispose();
(_toolDiscoveryService as IDisposable)?.Dispose();
(_serverManagementService as IDisposable)?.Dispose();
(_transportManager as IDisposable)?.Dispose();
_bridgeService = null; _bridgeService = null;
_clientService = null; _clientService = null;
_pathService = null; _pathService = null;
_pythonToolRegistryService = null;
_testRunnerService = null; _testRunnerService = null;
_toolSyncService = null;
_packageUpdateService = null; _packageUpdateService = null;
_platformService = null; _platformService = null;
_toolDiscoveryService = null;
_serverManagementService = null;
_transportManager = null;
} }
} }
} }

View File

@ -3,6 +3,7 @@ using System.Net;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using MCPForUnity.Editor.Constants;
namespace MCPForUnity.Editor.Services namespace MCPForUnity.Editor.Services
{ {
@ -11,8 +12,8 @@ namespace MCPForUnity.Editor.Services
/// </summary> /// </summary>
public class PackageUpdateService : IPackageUpdateService public class PackageUpdateService : IPackageUpdateService
{ {
private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck"; private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck;
private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion"; private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion;
private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
/// <inheritdoc/> /// <inheritdoc/>

View File

@ -1,6 +1,9 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
@ -12,164 +15,107 @@ namespace MCPForUnity.Editor.Services
/// </summary> /// </summary>
public class PathResolverService : IPathResolverService public class PathResolverService : IPathResolverService
{ {
private const string PythonDirOverrideKey = "MCPForUnity.PythonDirOverride"; public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null));
private const string UvPathOverrideKey = "MCPForUnity.UvPath"; public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null));
private const string ClaudeCliPathOverrideKey = "MCPForUnity.ClaudeCliPath";
public bool HasMcpServerOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(PythonDirOverrideKey, null)); public string GetUvxPath()
public bool HasUvPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvPathOverrideKey, null));
public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(ClaudeCliPathOverrideKey, null));
public string GetMcpServerPath()
{ {
// 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 try
{ {
return ServerInstaller.FindUvPath(); string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
if (!string.IsNullOrEmpty(overridePath))
{
return overridePath;
}
} }
catch 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() public string GetClaudeCliPath()
{ {
// Check for override first try
string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, null); {
string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath))
{ {
return overridePath; return overridePath;
} }
}
catch { /* ignore */ }
// Fall back to automatic detection if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return ExecPath.ResolveClaude(); {
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;
}
}
return null;
} }
public bool IsPythonDetected() public bool IsPythonDetected()
{ {
try try
{ {
// Windows-specific Python detection
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// 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 var psi = new ProcessStartInfo
{ {
FileName = "where", FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3",
Arguments = "python", Arguments = "--version",
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
CreateNoWindow = true CreateNoWindow = true
}; };
using (var p = Process.Start(psi)) using var p = Process.Start(psi);
{
string outp = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit(2000); p.WaitForExit(2000);
if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) return p.ExitCode == 0;
}
catch
{ {
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;
}
}
}
catch { }
return false; return false;
} }
public bool IsUvDetected()
{
return !string.IsNullOrEmpty(GetUvPath());
} }
public bool IsClaudeCliDetected() public bool IsClaudeCliDetected()
@ -177,36 +123,20 @@ namespace MCPForUnity.Editor.Services
return !string.IsNullOrEmpty(GetClaudeCliPath()); return !string.IsNullOrEmpty(GetClaudeCliPath());
} }
public void SetMcpServerOverride(string path) public void SetUvxPathOverride(string path)
{ {
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
{ {
ClearMcpServerOverride(); ClearUvxPathOverride();
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();
return; return;
} }
if (!File.Exists(path)) 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) public void SetClaudeCliPathOverride(string path)
@ -222,24 +152,17 @@ namespace MCPForUnity.Editor.Services
throw new ArgumentException("The selected Claude CLI executable does not exist"); throw new ArgumentException("The selected Claude CLI executable does not exist");
} }
EditorPrefs.SetString(ClaudeCliPathOverrideKey, path); EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path);
// Also update the ExecPath helper for backwards compatibility
ExecPath.SetClaudeCliPath(path);
} }
public void ClearMcpServerOverride() public void ClearUvxPathOverride()
{ {
EditorPrefs.DeleteKey(PythonDirOverrideKey); EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride);
}
public void ClearUvPathOverride()
{
EditorPrefs.DeleteKey(UvPathOverrideKey);
} }
public void ClearClaudeCliPathOverride() public void ClearClaudeCliPathOverride()
{ {
EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride);
} }
} }
} }

View File

@ -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();
}
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 2da2869749c764f16a45e010eefbd679
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e60df35c5a76462d8aaa8078da86d75
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec81a561be4c14c9cb243855d3273a94
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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}");
}
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 9ad084cf3b6c04174b9202bf63137bae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: d5876265244e44b0dbea3a1351bf24be guid: 8d189635a5d364f55a810203798c09ba
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@ -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();
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 042446a50a4744170bb294acf827376f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 27407cc9c1ea0412d80b9f8964a5a29d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65fc8ff4c9efb4fc98a0910ba7ca8b02
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 67ab8e43f6a804698bb5b216cdef0645
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3d467a63b6fad42fa975c731af4b83b3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -11,17 +11,15 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Tools.Prefabs; using MCPForUnity.Editor.Tools.Prefabs;
using MCPForUnity.Editor.Services.Transport;
namespace MCPForUnity.Editor namespace MCPForUnity.Editor.Services.Transport.Transports
{ {
/// <summary>
/// Outbound message structure for the writer thread
/// </summary>
class Outbound class Outbound
{ {
public byte[] Payload; public byte[] Payload;
@ -29,24 +27,22 @@ namespace MCPForUnity.Editor
public int? ReqId; public int? ReqId;
} }
/// <summary>
/// Queued command structure for main thread processing
/// </summary>
class QueuedCommand class QueuedCommand
{ {
public string CommandJson; public string CommandJson;
public TaskCompletionSource<string> Tcs; public TaskCompletionSource<string> Tcs;
public bool IsExecuting; public bool IsExecuting;
} }
[InitializeOnLoad] [InitializeOnLoad]
public static partial class MCPForUnityBridge public static class StdioBridgeHost
{ {
private static TcpListener listener; private static TcpListener listener;
private static bool isRunning = false; private static bool isRunning = false;
private static readonly object lockObj = new(); private static readonly object lockObj = new();
private static readonly object startStopLock = new(); private static readonly object startStopLock = new();
private static readonly object clientsLock = new(); private static readonly object clientsLock = new();
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new(); private static readonly HashSet<TcpClient> activeClients = new();
private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>()); private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
private static CancellationTokenSource cts; private static CancellationTokenSource cts;
private static Task listenerTask; private static Task listenerTask;
@ -59,19 +55,18 @@ namespace MCPForUnity.Editor
private static int heartbeatSeq = 0; private static int heartbeatSeq = 0;
private static Dictionary<string, QueuedCommand> commandQueue = new(); private static Dictionary<string, QueuedCommand> commandQueue = new();
private static int mainThreadId; private static int mainThreadId;
private static int currentUnityPort = 6400; // Dynamic port, starts with default private static int currentUnityPort = 6400;
private static bool isAutoConnectMode = false; private static bool isAutoConnectMode = false;
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads private static bool shouldRestartAfterReload = false;
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients private const ulong MaxFrameBytes = 64UL * 1024 * 1024;
private const int FrameIOTimeoutMs = 30000;
// IO diagnostics
private static long _ioSeq = 0; private static long _ioSeq = 0;
private static void IoInfo(string s) { McpLog.Info(s, always: false); } private static void IoInfo(string s) { McpLog.Info(s, always: false); }
// Debug helpers
private static bool IsDebugEnabled() private static bool IsDebugEnabled()
{ {
try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { return false; }
} }
private static void LogBreadcrumb(string stage) private static void LogBreadcrumb(string stage)
@ -86,28 +81,21 @@ namespace MCPForUnity.Editor
public static int GetCurrentPort() => currentUnityPort; public static int GetCurrentPort() => currentUnityPort;
public static bool IsAutoConnectMode() => isAutoConnectMode; public static bool IsAutoConnectMode() => isAutoConnectMode;
/// <summary>
/// Start with Auto-Connect mode - discovers new port and saves it
/// </summary>
public static void StartAutoConnect() public static void StartAutoConnect()
{ {
Stop(); // Stop current connection Stop();
try try
{ {
// Prefer stored project port and start using the robust Start() path (with retries/options)
currentUnityPort = PortManager.GetPortWithFallback(); currentUnityPort = PortManager.GetPortWithFallback();
Start(); Start();
isAutoConnectMode = true; isAutoConnectMode = true;
// Record telemetry for bridge startup
TelemetryHelper.RecordBridgeStartup(); TelemetryHelper.RecordBridgeStartup();
} }
catch (Exception ex) catch (Exception ex)
{ {
McpLog.Error($"Auto-connect failed: {ex.Message}"); McpLog.Error($"Auto-connect failed: {ex.Message}");
// Record telemetry for connection failure
TelemetryHelper.RecordBridgeConnection(false, ex.Message); TelemetryHelper.RecordBridgeConnection(false, ex.Message);
throw; throw;
} }
@ -132,11 +120,9 @@ namespace MCPForUnity.Editor
return Directory.Exists(fullPath); return Directory.Exists(fullPath);
} }
static MCPForUnityBridge() static StdioBridgeHost()
{ {
// Record the main thread ID for safe thread checks
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
// Start single writer thread for framed responses
try try
{ {
var writerThread = new Thread(() => var writerThread = new Thread(() =>
@ -148,10 +134,6 @@ namespace MCPForUnity.Editor
long seq = Interlocked.Increment(ref _ioSeq); long seq = Interlocked.Increment(ref _ioSeq);
IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}");
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
// Note: We currently have a per-connection 'stream' in the client handler. For simplicity,
// writes are performed inline there. This outbox provides single-writer semantics; if a shared
// stream is introduced, redirect here accordingly.
// No-op: actual write happens in client loop using WriteFrameAsync
sw.Stop(); sw.Stop();
IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}");
} }
@ -166,36 +148,35 @@ namespace MCPForUnity.Editor
} }
catch { } catch { }
// Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
// CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
{ {
return; return;
} }
// Defer start until the editor is idle and not compiling if (ShouldAutoStartBridge())
{
ScheduleInitRetry(); ScheduleInitRetry();
// Add a safety net update hook in case delayCall is missed during reload churn
if (!ensureUpdateHooked) if (!ensureUpdateHooked)
{ {
ensureUpdateHooked = true; ensureUpdateHooked = true;
EditorApplication.update += EnsureStartedOnEditorIdle; EditorApplication.update += EnsureStartedOnEditorIdle;
} }
}
EditorApplication.quitting += Stop; EditorApplication.quitting += Stop;
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
// Also coalesce play mode transitions into a deferred init EditorApplication.playModeStateChanged += _ =>
EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); {
if (ShouldAutoStartBridge())
{
ScheduleInitRetry();
}
};
} }
/// <summary>
/// Initialize the MCP bridge after Unity is fully loaded and compilation is complete.
/// This prevents repeated restarts during script compilation that cause port hopping.
/// </summary>
private static void InitializeAfterCompilation() private static void InitializeAfterCompilation()
{ {
initScheduled = false; initScheduled = false;
// Play-mode friendly: allow starting in play mode; only defer while compiling
if (IsCompiling()) if (IsCompiling())
{ {
ScheduleInitRetry(); ScheduleInitRetry();
@ -207,7 +188,6 @@ namespace MCPForUnity.Editor
Start(); Start();
if (!isRunning) if (!isRunning)
{ {
// If a race prevented start, retry later
ScheduleInitRetry(); ScheduleInitRetry();
} }
} }
@ -220,28 +200,35 @@ namespace MCPForUnity.Editor
return; return;
} }
initScheduled = true; initScheduled = true;
// Debounce: start ~200ms after the last trigger
nextStartAt = EditorApplication.timeSinceStartup + 0.20f; nextStartAt = EditorApplication.timeSinceStartup + 0.20f;
// Ensure the update pump is active
if (!ensureUpdateHooked) if (!ensureUpdateHooked)
{ {
ensureUpdateHooked = true; ensureUpdateHooked = true;
EditorApplication.update += EnsureStartedOnEditorIdle; EditorApplication.update += EnsureStartedOnEditorIdle;
} }
// Keep the original delayCall as a secondary path
EditorApplication.delayCall += InitializeAfterCompilation; EditorApplication.delayCall += InitializeAfterCompilation;
} }
// Safety net: ensure the bridge starts shortly after domain reload when editor is idle private static bool ShouldAutoStartBridge()
{
try
{
bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
return !useHttpTransport;
}
catch
{
return true;
}
}
private static void EnsureStartedOnEditorIdle() private static void EnsureStartedOnEditorIdle()
{ {
// Do nothing while compiling
if (IsCompiling()) if (IsCompiling())
{ {
return; return;
} }
// If already running, remove the hook
if (isRunning) if (isRunning)
{ {
EditorApplication.update -= EnsureStartedOnEditorIdle; EditorApplication.update -= EnsureStartedOnEditorIdle;
@ -249,7 +236,6 @@ namespace MCPForUnity.Editor
return; return;
} }
// Debounced start: wait until the scheduled time
if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt)
{ {
return; return;
@ -263,7 +249,6 @@ namespace MCPForUnity.Editor
isStarting = true; isStarting = true;
try try
{ {
// Attempt start; if it succeeds, remove the hook to avoid overhead
Start(); Start();
} }
finally finally
@ -277,7 +262,6 @@ namespace MCPForUnity.Editor
} }
} }
// Helper to check compilation status across Unity versions
private static bool IsCompiling() private static bool IsCompiling()
{ {
if (EditorApplication.isCompiling) if (EditorApplication.isCompiling)
@ -286,7 +270,7 @@ namespace MCPForUnity.Editor
} }
try try
{ {
System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (prop != null) if (prop != null)
{ {
@ -301,25 +285,21 @@ namespace MCPForUnity.Editor
{ {
lock (startStopLock) lock (startStopLock)
{ {
// Don't restart if already running on a working port
if (isRunning && listener != null) if (isRunning && listener != null)
{ {
if (IsDebugEnabled()) if (IsDebugEnabled())
{ {
McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}"); McpLog.Info($"StdioBridgeHost already running on port {currentUnityPort}");
} }
return; return;
} }
Stop(); Stop();
// Attempt fast bind with stored-port preference (sticky per-project)
try try
{ {
// Always consult PortManager first so we prefer the persisted project port
currentUnityPort = PortManager.GetPortWithFallback(); currentUnityPort = PortManager.GetPortWithFallback();
// Breadcrumb: Start
LogBreadcrumb("Start"); LogBreadcrumb("Start");
const int maxImmediateRetries = 3; const int maxImmediateRetries = 3;
@ -342,14 +322,12 @@ namespace MCPForUnity.Editor
} }
catch { } catch { }
#endif #endif
// Minimize TIME_WAIT by sending RST on close
try try
{ {
listener.Server.LingerState = new LingerOption(true, 0); listener.Server.LingerState = new LingerOption(true, 0);
} }
catch (Exception) catch (Exception)
{ {
// Ignore if not supported on platform
} }
listener.Start(); listener.Start();
break; break;
@ -362,12 +340,26 @@ namespace MCPForUnity.Editor
} }
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
{ {
// Port is occupied by another instance, get a new available port
int oldPort = currentUnityPort; int oldPort = currentUnityPort;
// Before switching ports, give the old one a brief chance to release if it looks like ours
try
{
if (PortManager.IsPortUsedByMCPForUnity(oldPort))
{
const int waitStepMs = 100;
int waited = 0;
while (waited < 300 && !PortManager.IsPortAvailable(oldPort))
{
Thread.Sleep(waitStepMs);
waited += waitStepMs;
}
}
}
catch { }
currentUnityPort = PortManager.GetPortWithFallback(); currentUnityPort = PortManager.GetPortWithFallback();
// GetPortWithFallback() may return the same port if it became available during wait
// or a different port if switching to an alternative
if (IsDebugEnabled()) if (IsDebugEnabled())
{ {
if (currentUnityPort == oldPort) if (currentUnityPort == oldPort)
@ -408,21 +400,18 @@ namespace MCPForUnity.Editor
isRunning = true; isRunning = true;
isAutoConnectMode = false; isAutoConnectMode = false;
string platform = Application.platform.ToString(); string platform = Application.platform.ToString();
string serverVer = ReadInstalledServerVersionSafe(); string serverVer = AssetPathUtility.GetPackageVersion();
McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); McpLog.Info($"StdioBridgeHost started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
// Start background listener with cooperative cancellation
cts = new CancellationTokenSource(); cts = new CancellationTokenSource();
listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
CommandRegistry.Initialize(); CommandRegistry.Initialize();
EditorApplication.update += ProcessCommands; EditorApplication.update += ProcessCommands;
// Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting -= Stop; } catch { }
try { EditorApplication.quitting += Stop; } catch { } try { EditorApplication.quitting += Stop; } catch { }
// Write initial heartbeat immediately
heartbeatSeq++; heartbeatSeq++;
WriteHeartbeat(false, "ready"); WriteHeartbeat(false, "ready");
nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f;
@ -446,10 +435,8 @@ namespace MCPForUnity.Editor
try try
{ {
// Mark as stopping early to avoid accept logging during disposal
isRunning = false; isRunning = false;
// Quiesce background listener quickly
var cancel = cts; var cancel = cts;
cts = null; cts = null;
try { cancel?.Cancel(); } catch { } try { cancel?.Cancel(); } catch { }
@ -457,17 +444,15 @@ namespace MCPForUnity.Editor
try { listener?.Stop(); } catch { } try { listener?.Stop(); } catch { }
listener = null; listener = null;
// Capture background task to wait briefly outside the lock
toWait = listenerTask; toWait = listenerTask;
listenerTask = null; listenerTask = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
McpLog.Error($"Error stopping MCPForUnityBridge: {ex.Message}"); McpLog.Error($"Error stopping StdioBridgeHost: {ex.Message}");
} }
} }
// Proactively close all active client sockets to unblock any pending reads
TcpClient[] toClose; TcpClient[] toClose;
lock (clientsLock) lock (clientsLock)
{ {
@ -479,19 +464,16 @@ namespace MCPForUnity.Editor
try { c.Close(); } catch { } try { c.Close(); } catch { }
} }
// Give the background loop a short window to exit without blocking the editor
if (toWait != null) if (toWait != null)
{ {
try { toWait.Wait(100); } catch { } try { toWait.Wait(100); } catch { }
} }
// Now unhook editor events safely
try { EditorApplication.update -= ProcessCommands; } catch { } try { EditorApplication.update -= ProcessCommands; } catch { }
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting -= Stop; } catch { }
// Clean up status file when Unity stops
try try
{ {
string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
@ -507,7 +489,7 @@ namespace MCPForUnity.Editor
if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}"); if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}");
} }
if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped."); if (IsDebugEnabled()) McpLog.Info("StdioBridgeHost stopped.");
} }
private static async Task ListenerLoopAsync(CancellationToken token) private static async Task ListenerLoopAsync(CancellationToken token)
@ -517,22 +499,18 @@ namespace MCPForUnity.Editor
try try
{ {
TcpClient client = await listener.AcceptTcpClientAsync(); TcpClient client = await listener.AcceptTcpClientAsync();
// Enable basic socket keepalive
client.Client.SetSocketOption( client.Client.SetSocketOption(
SocketOptionLevel.Socket, SocketOptionLevel.Socket,
SocketOptionName.KeepAlive, SocketOptionName.KeepAlive,
true true
); );
// Set longer receive timeout to prevent quick disconnections client.ReceiveTimeout = 60000;
client.ReceiveTimeout = 60000; // 60 seconds
// Fire and forget each client connection
_ = Task.Run(() => HandleClientAsync(client, token), token); _ = Task.Run(() => HandleClientAsync(client, token), token);
} }
catch (ObjectDisposedException) catch (ObjectDisposedException)
{ {
// Listener was disposed during stop/reload; exit quietly
if (!isRunning || token.IsCancellationRequested) if (!isRunning || token.IsCancellationRequested)
{ {
break; break;
@ -560,7 +538,6 @@ namespace MCPForUnity.Editor
lock (clientsLock) { activeClients.Add(client); } lock (clientsLock) { activeClients.Add(client); }
try try
{ {
// Framed I/O only; legacy mode removed
try try
{ {
if (IsDebugEnabled()) if (IsDebugEnabled())
@ -570,7 +547,6 @@ namespace MCPForUnity.Editor
} }
} }
catch { } catch { }
// Strict framing: always require FRAMING=1 and frame all I/O
try try
{ {
client.NoDelay = true; client.NoDelay = true;
@ -591,14 +567,13 @@ namespace MCPForUnity.Editor
catch (Exception ex) catch (Exception ex)
{ {
if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}"); if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}");
return; // abort this client return;
} }
while (isRunning && !token.IsCancellationRequested) while (isRunning && !token.IsCancellationRequested)
{ {
try try
{ {
// Strict framed mode only: enforced framed I/O for this connection
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
try try
@ -613,12 +588,9 @@ namespace MCPForUnity.Editor
string commandId = Guid.NewGuid().ToString(); string commandId = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// Special handling for ping command to avoid JSON parsing
if (commandText.Trim() == "ping") if (commandText.Trim() == "ping")
{ {
// Direct response to ping without going through JSON parsing
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
/*lang=json,strict*/
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
); );
await WriteFrameAsync(stream, pingResponseBytes); await WriteFrameAsync(stream, pingResponseBytes);
@ -635,7 +607,6 @@ namespace MCPForUnity.Editor
}; };
} }
// Wait for the handler to produce a response, but do not block indefinitely
string response; string response;
try try
{ {
@ -643,13 +614,11 @@ namespace MCPForUnity.Editor
var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
if (completed == tcs.Task) if (completed == tcs.Task)
{ {
// Got a result from the handler
respCts.Cancel(); respCts.Cancel();
response = tcs.Task.Result; response = tcs.Task.Result;
} }
else else
{ {
// Timeout: return a structured error so the client can recover
var timeoutResponse = new var timeoutResponse = new
{ {
status = "error", status = "error",
@ -672,8 +641,7 @@ namespace MCPForUnity.Editor
{ {
try { McpLog.Info("[MCP] sending framed response", always: false); } catch { } try { McpLog.Info("[MCP] sending framed response", always: false); } catch { }
} }
// Crash-proof and self-reporting writer logs (direct write to this client's stream) long seq = Interlocked.Increment(ref _ioSeq);
long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
byte[] responseBytes; byte[] responseBytes;
try try
{ {
@ -701,12 +669,11 @@ namespace MCPForUnity.Editor
} }
catch (Exception ex) catch (Exception ex)
{ {
// Treat common disconnects/timeouts as benign; only surface hard errors
string msg = ex.Message ?? string.Empty; string msg = ex.Message ?? string.Empty;
bool isBenign = bool isBenign =
msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
|| ex is System.IO.IOException; || ex is IOException;
if (isBenign) if (isBenign)
{ {
if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false); if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false);
@ -726,8 +693,7 @@ namespace MCPForUnity.Editor
} }
} }
// Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks private static async Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default)
private static async System.Threading.Tasks.Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default)
{ {
byte[] buffer = new byte[count]; byte[] buffer = new byte[count];
int offset = 0; int offset = 0;
@ -740,10 +706,9 @@ namespace MCPForUnity.Editor
? Timeout.Infinite ? Timeout.Infinite
: timeoutMs - (int)stopwatch.ElapsedMilliseconds; : timeoutMs - (int)stopwatch.ElapsedMilliseconds;
// If a finite timeout is configured and already elapsed, fail immediately
if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0)
{ {
throw new System.IO.IOException("Read timed out"); throw new IOException("Read timed out");
} }
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel);
@ -761,34 +726,34 @@ namespace MCPForUnity.Editor
#endif #endif
if (read == 0) if (read == 0)
{ {
throw new System.IO.IOException("Connection closed before reading expected bytes"); throw new IOException("Connection closed before reading expected bytes");
} }
offset += read; offset += read;
} }
catch (OperationCanceledException) when (!cancel.IsCancellationRequested) catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
{ {
throw new System.IO.IOException("Read timed out"); throw new IOException("Read timed out");
} }
} }
return buffer; return buffer;
} }
private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) private static Task WriteFrameAsync(NetworkStream stream, byte[] payload)
{ {
using var cts = new CancellationTokenSource(FrameIOTimeoutMs); using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
await WriteFrameAsync(stream, payload, cts.Token); return WriteFrameAsync(stream, payload, cts.Token);
} }
private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) private static async Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel)
{ {
if (payload == null) if (payload == null)
{ {
throw new System.ArgumentNullException(nameof(payload)); throw new ArgumentNullException(nameof(payload));
} }
if ((ulong)payload.LongLength > MaxFrameBytes) if ((ulong)payload.LongLength > MaxFrameBytes)
{ {
throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); throw new IOException($"Frame too large: {payload.LongLength}");
} }
byte[] header = new byte[8]; byte[] header = new byte[8];
WriteUInt64BigEndian(header, (ulong)payload.LongLength); WriteUInt64BigEndian(header, (ulong)payload.LongLength);
@ -801,19 +766,19 @@ namespace MCPForUnity.Editor
#endif #endif
} }
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) private static async Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
{ {
byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
ulong payloadLen = ReadUInt64BigEndian(header); ulong payloadLen = ReadUInt64BigEndian(header);
if (payloadLen > MaxFrameBytes) if (payloadLen > MaxFrameBytes)
{ {
throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); throw new IOException($"Invalid framed length: {payloadLen}");
} }
if (payloadLen == 0UL) if (payloadLen == 0UL)
throw new System.IO.IOException("Zero-length frames are not allowed"); throw new IOException("Zero-length frames are not allowed");
if (payloadLen > int.MaxValue) if (payloadLen > int.MaxValue)
{ {
throw new System.IO.IOException("Frame too large for buffer"); throw new IOException("Frame too large for buffer");
} }
int count = (int)payloadLen; int count = (int)payloadLen;
byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
@ -837,7 +802,7 @@ namespace MCPForUnity.Editor
{ {
if (dest == null || dest.Length < 8) if (dest == null || dest.Length < 8)
{ {
throw new System.ArgumentException("Destination buffer too small for UInt64"); throw new ArgumentException("Destination buffer too small for UInt64");
} }
dest[0] = (byte)(value >> 56); dest[0] = (byte)(value >> 56);
dest[1] = (byte)(value >> 48); dest[1] = (byte)(value >> 48);
@ -852,10 +817,9 @@ namespace MCPForUnity.Editor
private static void ProcessCommands() private static void ProcessCommands()
{ {
if (!isRunning) return; if (!isRunning) return;
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard if (Interlocked.Exchange(ref processingCommands, 1) == 1) return;
try try
{ {
// Heartbeat without holding the queue lock
double now = EditorApplication.timeSinceStartup; double now = EditorApplication.timeSinceStartup;
if (now >= nextHeartbeatAt) if (now >= nextHeartbeatAt)
{ {
@ -863,7 +827,6 @@ namespace MCPForUnity.Editor
nextHeartbeatAt = now + 0.5f; nextHeartbeatAt = now + 0.5f;
} }
// Snapshot under lock, then process outside to reduce contention
List<(string id, QueuedCommand command)> work; List<(string id, QueuedCommand command)> work;
lock (lockObj) lock (lockObj)
{ {
@ -884,10 +847,7 @@ namespace MCPForUnity.Editor
string commandText = queuedCommand.CommandJson; string commandText = queuedCommand.CommandJson;
TaskCompletionSource<string> tcs = queuedCommand.Tcs; TaskCompletionSource<string> tcs = queuedCommand.Tcs;
try if (string.IsNullOrWhiteSpace(commandText))
{
// Special case handling
if (string.IsNullOrEmpty(commandText))
{ {
var emptyResponse = new var emptyResponse = new
{ {
@ -895,15 +855,11 @@ namespace MCPForUnity.Editor
error = "Empty command received", error = "Empty command received",
}; };
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); } lock (lockObj) { commandQueue.Remove(id); }
continue; continue;
} }
// Trim the command text to remove any whitespace
commandText = commandText.Trim(); commandText = commandText.Trim();
// Non-JSON direct commands handling (like ping)
if (commandText == "ping") if (commandText == "ping")
{ {
var pingResponse = new var pingResponse = new
@ -916,7 +872,6 @@ namespace MCPForUnity.Editor
continue; continue;
} }
// Check if the command is valid JSON before attempting to deserialize
if (!IsValidJson(commandText)) if (!IsValidJson(commandText))
{ {
var invalidJsonResponse = new var invalidJsonResponse = new
@ -932,72 +887,7 @@ namespace MCPForUnity.Editor
continue; continue;
} }
// Normal JSON command processing ExecuteQueuedCommand(id, commandText, tcs);
Command command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null)
{
var nullCommandResponse = new
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object",
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
else
{
// Use JObject for parameters as handlers expect this
JObject paramsObject = command.@params ?? new JObject();
// Execute command (may be sync or async)
object result = CommandRegistry.ExecuteCommand(command.type, paramsObject, tcs);
// If result is null, it means async execution - TCS will be completed by the awaited task
// In this case, DON'T remove from queue yet, DON'T complete TCS
if (result == null)
{
// Async command - the task continuation will complete the TCS
// Setup cleanup when TCS completes - schedule on next frame to avoid race conditions
string asyncCommandId = id;
_ = tcs.Task.ContinueWith(_ =>
{
// Use EditorApplication.delayCall to schedule cleanup on main thread, next frame
EditorApplication.delayCall += () =>
{
lock (lockObj)
{
commandQueue.Remove(asyncCommandId);
}
};
});
continue; // Skip the queue removal below
}
// Synchronous result - complete TCS now
var response = new { status = "success", result };
tcs.SetResult(JsonConvert.SerializeObject(response));
}
}
catch (Exception ex)
{
McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new
{
status = "error",
error = ex.Message,
commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50
? commandText[..50] + "..."
: commandText,
};
string responseJson = JsonConvert.SerializeObject(response);
tcs.SetResult(responseJson);
}
// Remove from queue (only for sync commands - async ones skip with 'continue' above)
lock (lockObj) { commandQueue.Remove(id); }
} }
} }
finally finally
@ -1006,20 +896,60 @@ namespace MCPForUnity.Editor
} }
} }
// Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. private static void ExecuteQueuedCommand(string commandId, string payload, TaskCompletionSource<string> completionSource)
// Returns null on timeout or error; caller should provide a fallback error response. {
async void Runner()
{
try
{
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
string response = await TransportCommandDispatcher.ExecuteCommandJsonAsync(payload, cts.Token).ConfigureAwait(true);
completionSource.TrySetResult(response);
}
catch (OperationCanceledException)
{
var timeoutResponse = new
{
status = "error",
error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
};
completionSource.TrySetResult(JsonConvert.SerializeObject(timeoutResponse));
}
catch (Exception ex)
{
McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new
{
status = "error",
error = ex.Message,
receivedText = payload?.Length > 50
? payload[..50] + "..."
: payload,
};
completionSource.TrySetResult(JsonConvert.SerializeObject(response));
}
finally
{
lock (lockObj)
{
commandQueue.Remove(commandId);
}
}
}
Runner();
}
private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs) private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs)
{ {
if (func == null) return null; if (func == null) return null;
try try
{ {
// If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor.
if (mainThreadId == 0) if (mainThreadId == 0)
{ {
try { return func(); } try { return func(); }
catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); }
} }
// If we are already on the main thread, execute directly to avoid deadlocks
try try
{ {
if (Thread.CurrentThread.ManagedThreadId == mainThreadId) if (Thread.CurrentThread.ManagedThreadId == mainThreadId)
@ -1048,11 +978,10 @@ namespace MCPForUnity.Editor
} }
}; };
// Wait for completion with timeout (Editor thread will pump delayCall)
bool completed = tcs.Task.Wait(timeoutMs); bool completed = tcs.Task.Wait(timeoutMs);
if (!completed) if (!completed)
{ {
return null; // timeout return null;
} }
if (captured != null) if (captured != null)
{ {
@ -1066,7 +995,6 @@ namespace MCPForUnity.Editor
} }
} }
// Helper method to check if a string is valid JSON
private static bool IsValidJson(string text) private static bool IsValidJson(string text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
@ -1077,9 +1005,9 @@ namespace MCPForUnity.Editor
text = text.Trim(); text = text.Trim();
if ( if (
(text.StartsWith("{") && text.EndsWith("}")) (text.StartsWith("{") && text.EndsWith("}"))
|| // Object ||
(text.StartsWith("[") && text.EndsWith("]")) (text.StartsWith("[") && text.EndsWith("]"))
) // Array )
{ {
try try
{ {
@ -1095,113 +1023,45 @@ namespace MCPForUnity.Editor
return false; return false;
} }
private static string ExecuteCommand(Command command)
{
try
{
if (string.IsNullOrEmpty(command.type))
{
var errorResponse = new
{
status = "error",
error = "Command type cannot be empty",
details = "A valid command type is required for processing",
};
return JsonConvert.SerializeObject(errorResponse);
}
// Handle ping command for connection verification
if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase))
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
return JsonConvert.SerializeObject(pingResponse);
}
// Use JObject for parameters as the new handlers likely expect this
JObject paramsObject = command.@params ?? new JObject();
object result = CommandRegistry.GetHandler(command.type)(paramsObject);
// Standard success response format
var response = new { status = "success", result };
return JsonConvert.SerializeObject(response);
}
catch (Exception ex)
{
// Log the detailed error in Unity for debugging
McpLog.Error($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}");
// Standard error response format
var response = new
{
status = "error",
error = ex.Message, // Provide the specific error message
command = command?.type ?? "Unknown", // Include the command type if available
stackTrace = ex.StackTrace, // Include stack trace for detailed debugging
paramsSummary = command?.@params != null
? GetParamsSummary(command.@params)
: "No parameters", // Summarize parameters for context
};
return JsonConvert.SerializeObject(response);
}
}
private static object HandleManageScene(JObject paramsObject)
{
try
{
if (IsDebugEnabled()) McpLog.Info("[MCP] manage_scene: dispatching to main thread");
var sw = System.Diagnostics.Stopwatch.StartNew();
var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs);
sw.Stop();
if (IsDebugEnabled()) McpLog.Info($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms");
return r ?? Response.Error("manage_scene returned null (timeout or error)");
}
catch (Exception ex)
{
return Response.Error($"manage_scene dispatch error: {ex.Message}");
}
}
// Helper method to get a summary of parameters for error reporting
private static string GetParamsSummary(JObject @params)
{
try
{
return @params == null || !@params.HasValues
? "No parameters"
: string.Join(
", ",
@params
.Properties()
.Select(static p =>
$"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}"
)
);
}
catch
{
return "Could not summarize parameters";
}
}
// Heartbeat/status helpers
private static void OnBeforeAssemblyReload() private static void OnBeforeAssemblyReload()
{ {
// Stop cleanly before reload so sockets close and clients see 'reloading' if (isRunning)
{
shouldRestartAfterReload = true;
}
try { Stop(); } catch { } try { Stop(); } catch { }
// Avoid file I/O or heavy work here
} }
private static void OnAfterAssemblyReload() private static void OnAfterAssemblyReload()
{ {
// Will be overwritten by Start(), but mark as alive quickly
WriteHeartbeat(false, "idle"); WriteHeartbeat(false, "idle");
LogBreadcrumb("Idle"); LogBreadcrumb("Idle");
// Schedule a safe restart after reload to avoid races during compilation bool shouldResume = ShouldAutoStartBridge() || shouldRestartAfterReload;
if (shouldRestartAfterReload)
{
shouldRestartAfterReload = false;
}
if (!shouldResume)
{
return;
}
// If we're not compiling, try to bring the bridge up immediately to avoid depending on editor focus.
if (!IsCompiling())
{
try
{
Start();
return; // Successful immediate start; no need to schedule a delayed retry
}
catch (Exception ex)
{
// Fall through to delayed retry if immediate start fails
McpLog.Warn($"Immediate STDIO bridge restart after reload failed: {ex.Message}");
}
}
// Fallback path when compiling or if immediate start failed
ScheduleInitRetry(); ScheduleInitRetry();
} }
@ -1209,7 +1069,6 @@ namespace MCPForUnity.Editor
{ {
try try
{ {
// Allow override of status directory (useful in CI/containers)
string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR");
if (string.IsNullOrWhiteSpace(dir)) if (string.IsNullOrWhiteSpace(dir))
{ {
@ -1218,14 +1077,12 @@ namespace MCPForUnity.Editor
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
// Extract project name from path
string projectName = "Unknown"; string projectName = "Unknown";
try try
{ {
string projectPath = Application.dataPath; string projectPath = Application.dataPath;
if (!string.IsNullOrEmpty(projectPath)) if (!string.IsNullOrEmpty(projectPath))
{ {
// Remove trailing /Assets or \Assets
projectPath = projectPath.TrimEnd('/', '\\'); projectPath = projectPath.TrimEnd('/', '\\');
if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase))
{ {
@ -1255,26 +1112,9 @@ namespace MCPForUnity.Editor
} }
catch (Exception) catch (Exception)
{ {
// Best-effort only
} }
} }
private static string ReadInstalledServerVersionSafe()
{
try
{
string serverSrc = ServerInstaller.GetServerPath();
string verFile = Path.Combine(serverSrc, "server_version.txt");
if (File.Exists(verFile))
{
string v = File.ReadAllText(verFile)?.Trim();
if (!string.IsNullOrEmpty(v)) return v;
}
}
catch { }
return "unknown";
}
private static string ComputeProjectHash(string input) private static string ComputeProjectHash(string input)
{ {
try try

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fd295cefe518e438693c12e9c7f37488
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2743f3468d5f433dbf2220f0838d8d1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,690 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Transport.Transports
{
/// <summary>
/// Maintains a persistent WebSocket connection to the MCP server plugin hub.
/// Handles registration, keep-alives, and command dispatch back into Unity via
/// <see cref="TransportCommandDispatcher"/>.
/// </summary>
public class WebSocketTransportClient : IMcpTransportClient, IDisposable
{
private const string TransportDisplayName = "websocket";
private static readonly TimeSpan[] ReconnectSchedule =
{
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
};
private static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(15);
private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30);
private readonly IToolDiscoveryService _toolDiscoveryService;
private ClientWebSocket _socket;
private CancellationTokenSource _lifecycleCts;
private CancellationTokenSource _connectionCts;
private Task _receiveTask;
private Task _keepAliveTask;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private Uri _endpointUri;
private string _sessionId;
private string _projectHash;
private string _projectName;
private string _unityVersion;
private TimeSpan _keepAliveInterval = DefaultKeepAliveInterval;
private TimeSpan _socketKeepAliveInterval = DefaultKeepAliveInterval;
private volatile bool _isConnected;
private int _isReconnectingFlag;
private TransportState _state = TransportState.Disconnected(TransportDisplayName, "Transport not started");
private bool _disposed;
public WebSocketTransportClient(IToolDiscoveryService toolDiscoveryService = null)
{
_toolDiscoveryService = toolDiscoveryService;
}
public bool IsConnected => _isConnected;
public string TransportName => TransportDisplayName;
public TransportState State => _state;
public async Task<bool> StartAsync()
{
// Capture identity values on the main thread before any async context switching
_projectName = ProjectIdentityUtility.GetProjectName();
_projectHash = ProjectIdentityUtility.GetProjectHash();
_unityVersion = Application.unityVersion;
await StopAsync();
_lifecycleCts = new CancellationTokenSource();
_endpointUri = BuildWebSocketUri(HttpEndpointUtility.GetBaseUrl());
_sessionId = null;
if (!await EstablishConnectionAsync(_lifecycleCts.Token))
{
await StopAsync();
return false;
}
// State is connected but session ID might be pending until 'registered' message
_state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString());
_isConnected = true;
return true;
}
public async Task StopAsync()
{
if (_lifecycleCts == null)
{
return;
}
try
{
_lifecycleCts.Cancel();
}
catch { }
await StopConnectionLoopsAsync().ConfigureAwait(false);
if (_socket != null)
{
try
{
if (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.CloseReceived)
{
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Shutdown", CancellationToken.None).ConfigureAwait(false);
}
}
catch { }
finally
{
_socket.Dispose();
_socket = null;
}
}
_isConnected = false;
_state = TransportState.Disconnected(TransportDisplayName);
_lifecycleCts.Dispose();
_lifecycleCts = null;
}
public async Task<bool> VerifyAsync()
{
if (_socket == null || _socket.State != WebSocketState.Open)
{
return false;
}
if (_lifecycleCts == null)
{
return false;
}
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_lifecycleCts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
await SendPongAsync(timeoutCts.Token).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Verify ping failed: {ex.Message}");
return false;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
try
{
// Ensure background loops are stopped before disposing shared resources
StopAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Dispose failed to stop cleanly: {ex.Message}");
}
_sendLock?.Dispose();
_socket?.Dispose();
_lifecycleCts?.Dispose();
_disposed = true;
}
private async Task<bool> EstablishConnectionAsync(CancellationToken token)
{
await StopConnectionLoopsAsync().ConfigureAwait(false);
_connectionCts?.Dispose();
_connectionCts = CancellationTokenSource.CreateLinkedTokenSource(token);
CancellationToken connectionToken = _connectionCts.Token;
_socket?.Dispose();
_socket = new ClientWebSocket();
_socket.Options.KeepAliveInterval = _socketKeepAliveInterval;
try
{
await _socket.ConnectAsync(_endpointUri, connectionToken).ConfigureAwait(false);
}
catch (Exception ex)
{
McpLog.Error($"[WebSocket] Connection failed: {ex.Message}");
return false;
}
StartBackgroundLoops(connectionToken);
try
{
await SendRegisterAsync(connectionToken).ConfigureAwait(false);
}
catch (Exception ex)
{
McpLog.Error($"[WebSocket] Registration failed: {ex.Message}");
return false;
}
return true;
}
/// <summary>
/// Stops the connection loops and disposes of the connection CTS.
/// Particularly useful when reconnecting, we want to ensure that background loops are cancelled correctly before starting new oens
/// </summary>
/// <param name="awaitTasks">Whether to await the receive and keep alive tasks before disposing.</param>
private async Task StopConnectionLoopsAsync(bool awaitTasks = true)
{
if (_connectionCts != null && !_connectionCts.IsCancellationRequested)
{
try { _connectionCts.Cancel(); } catch { }
}
if (_receiveTask != null)
{
if (awaitTasks)
{
try { await _receiveTask.ConfigureAwait(false); } catch { }
_receiveTask = null;
}
else if (_receiveTask.IsCompleted)
{
_receiveTask = null;
}
}
if (_keepAliveTask != null)
{
if (awaitTasks)
{
try { await _keepAliveTask.ConfigureAwait(false); } catch { }
_keepAliveTask = null;
}
else if (_keepAliveTask.IsCompleted)
{
_keepAliveTask = null;
}
}
if (_connectionCts != null)
{
_connectionCts.Dispose();
_connectionCts = null;
}
}
private void StartBackgroundLoops(CancellationToken token)
{
if ((_receiveTask != null && !_receiveTask.IsCompleted) || (_keepAliveTask != null && !_keepAliveTask.IsCompleted))
{
return;
}
_receiveTask = Task.Run(() => ReceiveLoopAsync(token), CancellationToken.None);
_keepAliveTask = Task.Run(() => KeepAliveLoopAsync(token), CancellationToken.None);
}
private async Task ReceiveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
string message = await ReceiveMessageAsync(token).ConfigureAwait(false);
if (message == null)
{
continue;
}
await HandleMessageAsync(message, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (WebSocketException wse)
{
McpLog.Warn($"[WebSocket] Receive loop error: {wse.Message}");
await HandleSocketClosureAsync(wse.Message).ConfigureAwait(false);
break;
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Unexpected receive error: {ex.Message}");
await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false);
break;
}
}
}
private async Task<string> ReceiveMessageAsync(CancellationToken token)
{
if (_socket == null)
{
return null;
}
byte[] rentedBuffer = ArrayPool<byte>.Shared.Rent(8192);
var buffer = new ArraySegment<byte>(rentedBuffer);
using var ms = new MemoryStream(8192);
try
{
while (!token.IsCancellationRequested)
{
WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, token).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
await HandleSocketClosureAsync(result.CloseStatusDescription ?? "Server closed connection").ConfigureAwait(false);
return null;
}
if (result.Count > 0)
{
ms.Write(buffer.Array!, buffer.Offset, result.Count);
}
if (result.EndOfMessage)
{
break;
}
}
if (ms.Length == 0)
{
return null;
}
return Encoding.UTF8.GetString(ms.ToArray());
}
finally
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}
private async Task HandleMessageAsync(string message, CancellationToken token)
{
JObject payload;
try
{
payload = JObject.Parse(message);
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Invalid JSON payload: {ex.Message}");
return;
}
string messageType = payload.Value<string>("type") ?? string.Empty;
switch (messageType)
{
case "welcome":
ApplyWelcome(payload);
break;
case "registered":
await HandleRegisteredAsync(payload, token).ConfigureAwait(false);
break;
case "execute":
await HandleExecuteAsync(payload, token).ConfigureAwait(false);
break;
case "ping":
await SendPongAsync(token).ConfigureAwait(false);
break;
default:
// No-op for unrecognised types (keep-alives, telemetry, etc.)
break;
}
}
private void ApplyWelcome(JObject payload)
{
int? keepAliveSeconds = payload.Value<int?>("keepAliveInterval");
if (keepAliveSeconds.HasValue && keepAliveSeconds.Value > 0)
{
_keepAliveInterval = TimeSpan.FromSeconds(keepAliveSeconds.Value);
_socketKeepAliveInterval = _keepAliveInterval;
}
int? serverTimeoutSeconds = payload.Value<int?>("serverTimeout");
if (serverTimeoutSeconds.HasValue)
{
int sourceSeconds = keepAliveSeconds ?? serverTimeoutSeconds.Value;
int safeSeconds = Math.Max(5, Math.Min(serverTimeoutSeconds.Value, sourceSeconds));
_socketKeepAliveInterval = TimeSpan.FromSeconds(safeSeconds);
}
}
private async Task HandleRegisteredAsync(JObject payload, CancellationToken token)
{
string newSessionId = payload.Value<string>("session_id");
if (!string.IsNullOrEmpty(newSessionId))
{
_sessionId = newSessionId;
ProjectIdentityUtility.SetSessionId(_sessionId);
_state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString());
McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}");
await SendRegisterToolsAsync(token).ConfigureAwait(false);
}
}
private async Task SendRegisterToolsAsync(CancellationToken token)
{
if (_toolDiscoveryService == null) return;
var tools = _toolDiscoveryService.DiscoverAllTools();
var toolsArray = new JArray();
foreach (var tool in tools)
{
var toolObj = new JObject
{
["name"] = tool.Name,
["description"] = tool.Description,
["structured_output"] = tool.StructuredOutput,
["requires_polling"] = tool.RequiresPolling,
["poll_action"] = tool.PollAction
};
var paramsArray = new JArray();
if (tool.Parameters != null)
{
foreach (var p in tool.Parameters)
{
paramsArray.Add(new JObject
{
["name"] = p.Name,
["description"] = p.Description,
["type"] = p.Type,
["required"] = p.Required,
["default_value"] = p.DefaultValue
});
}
}
toolObj["parameters"] = paramsArray;
toolsArray.Add(toolObj);
}
var payload = new JObject
{
["type"] = "register_tools",
["tools"] = toolsArray
};
await SendJsonAsync(payload, token).ConfigureAwait(false);
McpLog.Info($"[WebSocket] Sent {tools.Count} tools registration");
}
private async Task HandleExecuteAsync(JObject payload, CancellationToken token)
{
string commandId = payload.Value<string>("id");
string commandName = payload.Value<string>("name");
JObject parameters = payload.Value<JObject>("params") ?? new JObject();
int timeoutSeconds = payload.Value<int?>("timeout") ?? (int)DefaultCommandTimeout.TotalSeconds;
if (string.IsNullOrEmpty(commandId) || string.IsNullOrEmpty(commandName))
{
McpLog.Warn("[WebSocket] Invalid execute payload (missing id or name)");
return;
}
var commandEnvelope = new JObject
{
["type"] = commandName,
["params"] = parameters
};
string responseJson;
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)));
responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
responseJson = JsonConvert.SerializeObject(new
{
status = "error",
error = $"Command '{commandName}' timed out after {timeoutSeconds} seconds"
});
}
catch (Exception ex)
{
responseJson = JsonConvert.SerializeObject(new
{
status = "error",
error = ex.Message
});
}
JToken resultToken;
try
{
resultToken = JToken.Parse(responseJson);
}
catch
{
resultToken = new JObject
{
["status"] = "error",
["error"] = "Invalid response payload"
};
}
var responsePayload = new JObject
{
["type"] = "command_result",
["id"] = commandId,
["result"] = resultToken
};
await SendJsonAsync(responsePayload, token).ConfigureAwait(false);
}
private async Task KeepAliveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(_keepAliveInterval, token).ConfigureAwait(false);
if (_socket == null || _socket.State != WebSocketState.Open)
{
break;
}
await SendPongAsync(token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
McpLog.Warn($"[WebSocket] Keep-alive failed: {ex.Message}");
await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false);
break;
}
}
}
private async Task SendRegisterAsync(CancellationToken token)
{
var registerPayload = new JObject
{
["type"] = "register",
// session_id is now server-authoritative; omitted here or sent as null
["project_name"] = _projectName,
["project_hash"] = _projectHash,
["unity_version"] = _unityVersion
};
await SendJsonAsync(registerPayload, token).ConfigureAwait(false);
}
private Task SendPongAsync(CancellationToken token)
{
var payload = new JObject
{
["type"] = "pong",
};
return SendJsonAsync(payload, token);
}
private async Task SendJsonAsync(JObject payload, CancellationToken token)
{
if (_socket == null)
{
throw new InvalidOperationException("WebSocket is not initialised");
}
string json = payload.ToString(Formatting.None);
byte[] bytes = Encoding.UTF8.GetBytes(json);
var buffer = new ArraySegment<byte>(bytes);
await _sendLock.WaitAsync(token).ConfigureAwait(false);
try
{
if (_socket.State != WebSocketState.Open)
{
throw new InvalidOperationException("WebSocket is not open");
}
await _socket.SendAsync(buffer, WebSocketMessageType.Text, true, token).ConfigureAwait(false);
}
finally
{
_sendLock.Release();
}
}
private async Task HandleSocketClosureAsync(string reason)
{
if (_lifecycleCts == null || _lifecycleCts.IsCancellationRequested)
{
return;
}
if (Interlocked.CompareExchange(ref _isReconnectingFlag, 1, 0) != 0)
{
return;
}
_isConnected = false;
_state = _state.WithError(reason ?? "Connection closed");
McpLog.Warn($"[WebSocket] Connection closed: {reason}");
await StopConnectionLoopsAsync(awaitTasks: false).ConfigureAwait(false);
_ = Task.Run(() => AttemptReconnectAsync(_lifecycleCts.Token), CancellationToken.None);
}
private async Task AttemptReconnectAsync(CancellationToken token)
{
try
{
await StopConnectionLoopsAsync().ConfigureAwait(false);
foreach (TimeSpan delay in ReconnectSchedule)
{
if (token.IsCancellationRequested)
{
return;
}
if (delay > TimeSpan.Zero)
{
try { await Task.Delay(delay, token).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
if (await EstablishConnectionAsync(token).ConfigureAwait(false))
{
_state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString());
_isConnected = true;
McpLog.Info("[WebSocket] Reconnected to MCP server");
return;
}
}
}
finally
{
Interlocked.Exchange(ref _isReconnectingFlag, 0);
}
_state = TransportState.Disconnected(TransportDisplayName, "Failed to reconnect");
}
private static Uri BuildWebSocketUri(string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = "http://localhost:8080";
}
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var httpUri))
{
throw new InvalidOperationException($"Invalid MCP base URL: {baseUrl}");
}
string scheme = httpUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws";
string builder = $"{scheme}://{httpUri.Authority}";
if (!string.IsNullOrEmpty(httpUri.AbsolutePath) && httpUri.AbsolutePath != "/")
{
builder += httpUri.AbsolutePath.TrimEnd('/');
}
builder += "/hub/plugin";
return new Uri(builder);
}
}
}

Some files were not shown because too many files have changed in this diff Show More