# Directory Structure ``` ├── .gitignore ├── CHANGELOG.md ├── CHANGELOG.md.meta ├── CODE_OF_CONDUCT.md ├── CODE_OF_CONDUCT.md.meta ├── Editor │ ├── GamePilot.UnityMCP.asmdef │ ├── GamePilot.UnityMCP.asmdef.meta │ ├── MCPCodeExecutor.cs │ ├── MCPCodeExecutor.cs.meta │ ├── MCPConnectionManager.cs │ ├── MCPConnectionManager.cs.meta │ ├── MCPDataCollector.cs │ ├── MCPDataCollector.cs.meta │ ├── MCPLogger.cs │ ├── MCPLogger.cs.meta │ ├── MCPManager.cs │ ├── MCPManager.cs.meta │ ├── MCPMessageHandler.cs │ ├── MCPMessageHandler.cs.meta │ ├── MCPMessageSender.cs │ ├── MCPMessageSender.cs.meta │ ├── Models │ │ ├── MCPEditorState.cs │ │ ├── MCPEditorState.cs.meta │ │ ├── MCPSceneInfo.cs │ │ └── MCPSceneInfo.cs.meta │ ├── Models.meta │ ├── UI │ │ ├── MCPDebugSettings.json │ │ ├── MCPDebugSettings.json.meta │ │ ├── MCPDebugWindow.cs │ │ ├── MCPDebugWindow.cs.meta │ │ ├── MCPDebugWindow.uss │ │ ├── MCPDebugWindow.uss.meta │ │ ├── MCPDebugWindow.uxml │ │ └── MCPDebugWindow.uxml.meta │ └── UI.meta ├── Editor.meta ├── LICENSE ├── LICENSE.meta ├── mcpInspector.png ├── mcpInspector.png.meta ├── mcpServer │ ├── .dockerignore │ ├── .env.example │ ├── build │ │ ├── filesystemTools.js │ │ ├── filesystemTools.js.meta │ │ ├── index.js │ │ ├── index.js.meta │ │ ├── toolDefinitions.js │ │ ├── toolDefinitions.js.meta │ │ ├── types.js │ │ ├── types.js.meta │ │ ├── websocketHandler.js │ │ └── websocketHandler.js.meta │ ├── build.meta │ ├── docker-compose.yml │ ├── docker-compose.yml.meta │ ├── Dockerfile │ ├── Dockerfile.meta │ ├── MCPSummary.md │ ├── MCPSummary.md.meta │ ├── node_modules.meta │ ├── package-lock.json │ ├── package-lock.json.meta │ ├── package.json │ ├── package.json.meta │ ├── smithery.yaml │ ├── src │ │ ├── filesystemTools.ts │ │ ├── filesystemTools.ts.meta │ │ ├── index.ts │ │ ├── index.ts.meta │ │ ├── toolDefinitions.ts │ │ ├── toolDefinitions.ts.meta │ │ ├── types.ts │ │ ├── types.ts.meta │ │ ├── websocketHandler.ts │ │ └── websocketHandler.ts.meta │ ├── src.meta │ ├── tsconfig.json │ └── tsconfig.json.meta ├── mcpServer.meta ├── package.json ├── package.json.meta ├── README.md └── README.md.meta ``` # Files -------------------------------------------------------------------------------- /mcpServer/.dockerignore: -------------------------------------------------------------------------------- ``` node_modules npm-debug.log .git .github .vscode *.md build/ .env ``` -------------------------------------------------------------------------------- /mcpServer/.env.example: -------------------------------------------------------------------------------- ``` # Unity MCP Server Configuration # WebSocket port for Unity Editor connection MCP_WEBSOCKET_PORT=5010 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # vitepress build output **/.vitepress/dist # vitepress cache directory **/.vitepress/cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* #unity stuff # This .gitignore file should be placed at the root of your Unity project directory # # Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore # .utmp/ /[Ll]ibrary/ /[Tt]emp/ /[Oo]bj/ /[Bb]uild/ /[Bb]uilds/ /[Ll]ogs/ /[Uu]ser[Ss]ettings/ # MemoryCaptures can get excessive in size. # They also could contain extremely sensitive data /[Mm]emoryCaptures/ # Recordings can get excessive in size /[Rr]ecordings/ # Uncomment this line if you wish to ignore the asset store tools plugin # /[Aa]ssets/AssetStoreTools* # Autogenerated Jetbrains Rider plugin /[Aa]ssets/Plugins/Editor/JetBrains* # Visual Studio cache directory .vs/ # Gradle cache directory .gradle/ # Autogenerated VS/MD/Consulo solution and project files ExportedObj/ .consulo/ *.csproj *.unityproj *.sln *.suo *.tmp *.user *.userprefs *.pidb *.booproj *.svd *.pdb *.mdb *.opendb *.VC.db # Unity3D generated meta files *.pidb.meta *.pdb.meta *.mdb.meta # Unity3D generated file on crash reports sysinfo.txt # Builds *.apk *.aab *.unitypackage *.unitypackage.meta *.app # Crashlytics generated file crashlytics-build.properties # Packed Addressables /[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin* # Temporary auto-generated Android Assets /[Aa]ssets/[Ss]treamingAssets/aa.meta /[Aa]ssets/[Ss]treamingAssets/aa/* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # 🚀 Advacned Unity MCP Integration [](https://modelcontextprotocol.io/introduction) [](https://smithery.ai/server/@quazaai/unitymcpintegration) [](https://unity.com) [](https://nodejs.org) [](https://www.typescriptlang.org) [](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) [](https://github.com/quazaai/UnityMCPIntegration/stargazers) [](https://github.com/quazaai/UnityMCPIntegration/network/members) [](https://github.com/quazaai/UnityMCPIntegration/blob/main/LICENSE) <div align="center"> <img src="mcpInspector.png" alt="Unity MCP Inspector" width="400" align="right" style="margin-left: 20px; margin-bottom: 20px;"/> </div> This package provides a seamless integration between [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) and Unity Editor, allowing AI assistants to understand and interact with your Unity projects in real-time. With this integration, AI assistants can access information about your scene hierarchy, project settings, and execute code directly in the Unity Editor context. ## 📚 Features - Browse and manipulate project files directly - Access real-time information about your Unity project - Understand your scene hierarchy and game objects - Execute C# code directly in the Unity Editor - Monitor logs and errors - Control the Editor's play mode - Wait For Code Execution ## 🚀 Getting Started ### Prerequisites - Unity 2021.3 or later - Node.js 18+ (for running the MCP server) ### Installation #### 1. Install Unity Package You have several options to install the Unity package: **Option A: Package Manager (Git URL)** 1. Open the Unity Package Manager (`Window > Package Manager`) 2. Click the `+` button and select `Add package from git URL...` 3. Enter the repository URL: `https://github.com/quazaai/UnityMCPIntegration.git` 4. Click `Add` **Option B: Import Custom Package** 1. Clone this repository or [download it as a unityPackage](https://github.com/quazaai/UnityMCPIntegration/releases) 2. In Unity, go to `Assets > Import Package > Custom Package` 3. Select the `UnityMCPIntegration.unitypackage` file #### 2. Set up the MCP Server You have two options to run the MCP server: **Option A: Run the server directly** 1. Navigate to the `mcpServer (likely <path-to-project>\Library\PackageCache\com.quaza.unitymcp@d2b8f1260bca\mcpServer\)` directory 2. Install dependencies: ``` npm install ``` 3. Run the server: ``` node build/index.js ``` **Option B: Add to MCP Host configuration** Add the server to your MCP Host configuration for Claude Desktop, Custom Implementation etc ```json { "mcpServers": { "unity-mcp-server": { "command": "node", "args": [ "path-to-project>\\Library\\PackageCache\\com.quaza.unitymcp@d2b8f1260bca\\mcpServer\\mcpServer\\build\\index.js" ], "env": { "MCP_WEBSOCKET_PORT": "5010" } } } } ``` ### Demo Video [](https://www.youtube.com/watch?v=GxTlahBXs74) ### Installing via Smithery To install Unity MCP Integration for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@quazaai/unitymcpintegration): ```bash npx -y @smithery/cli install @quazaai/unitymcpintegration --client claude ``` ### 🔧 Usage #### Debugging and Monitoring You can open the MCP Debug window in Unity to monitor the connection and test features: 1. Go to `Window > MCP Debug` 2. Use the debug window to: - Check connection status - Test code execution - View logs - Monitor events #### Available Tools The Unity MCP integration provides several tools to AI assistants: ##### Unity Editor Tools - **get_editor_state**: Get comprehensive information about the Unity project and editor state - **get_current_scene_info**: Get detailed information about the current scene - **get_game_objects_info**: Get information about specific GameObjects in the scene - **execute_editor_command**: Execute C# code directly in the Unity Editor - **get_logs**: Retrieve and filter Unity console logs - **verify_connection**: Check if there's an active connection to Unity Editor ##### Filesystem Tools - **read_file**: Read contents of a file in your Unity project - **read_multiple_files**: Read multiple files at once - **write_file**: Create or overwrite a file with new content - **edit_file**: Make targeted edits to existing files with diff preview - **list_directory**: Get a listing of files and folders in a directory - **directory_tree**: Get a hierarchical view of directories and files - **search_files**: Find files matching a search pattern - **get_file_info**: Get metadata about a specific file or directory - **find_assets_by_type**: Find all assets of a specific type (e.g. Material, Prefab) - **list_scripts**: Get a listing of all C# scripts in the project File paths can be absolute or relative to the Unity project's Assets folder. For example, `"Scenes/MyScene.unity"` refers to `<project>/Assets/Scenes/MyScene.unity`. ## 🛠️ Architecture The integration consists of two main components: 1. **Unity Plugin (C#)**: Resides in the Unity Editor and provides access to Editor APIs 2. **MCP Server (TypeScript/Node.js)**: Implements the MCP protocol and communicates with the Unity plugin Communication between them happens via WebSocket, transferring JSON messages for commands and data. ## File System Access The Unity MCP integration now includes powerful filesystem tools that allow AI assistants to: - Browse, read, and edit files in your Unity project - Create new files and directories - Search for specific files or asset types - Analyze your project structure - Make targeted code changes with diff previews All file operations are restricted to the Unity project directory for security. The system intelligently handles both absolute and relative paths, always resolving them relative to your project's Assets folder for convenience. Example usages: - Get a directory listing: `list_directory(path: "Scenes")` - Read a script file: `read_file(path: "Scripts/Player.cs")` - Edit a configuration file: `edit_file(path: "Resources/config.json", edits: [{oldText: "value: 10", newText: "value: 20"}], dryRun: true)` - Find all materials: `find_assets_by_type(assetType: "Material")` ## 👥 Contributing Contributions are welcome! Here's how you can contribute: 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Commit your changes (`git commit -m 'Add some amazing feature'`) 5. Push to the branch (`git push origin feature/amazing-feature`) 6. Open a Pull Request ### Development Setup **Unity Side**: - Open the project in Unity - Modify the C# scripts in the `UnityMCPConnection/Editor` directory **Server Side**: - Navigate to the `mcpServer` directory - Install dependencies: `npm install` - Make changes to the TypeScript files in the `src` directory - Build the server: `npm run build` - Run the server: `node build/index.js` ## 📄 License This project is licensed under the MIT License - see the LICENSE file for details. ## 📞 Support If you encounter any issues or have questions, please file an issue on the GitHub repository. ``` -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- ```markdown Contributor Covenant Code of Conduct Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. Our Standards Examples of behavior that contributes to creating a positive environment include: Using welcoming and inclusive language Being respectful of differing viewpoints and experiences Gracefully accepting constructive criticism Focusing on what is best for the community Showing empathy towards other community members Examples of unacceptable behavior by participants include: The use of sexualized language or imagery and unwelcome sexual attention or advances Trolling, insulting/derogatory comments, and personal or political attacks Public or private harassment Publishing others' private information, such as a physical or electronic address, without explicit permission Other conduct which could reasonably be considered inappropriate in a professional setting Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at {{ email }}. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. Attribution This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html ``` -------------------------------------------------------------------------------- /Editor/UI/MCPDebugSettings.json: -------------------------------------------------------------------------------- ```json { "port": 5010, "autoReconnect": false, "globalLoggingEnabled": false, "componentLoggingEnabled": {} } ``` -------------------------------------------------------------------------------- /mcpServer/docker-compose.yml: -------------------------------------------------------------------------------- ```yaml version: '3.8' services: unity-mcp-server: build: . ports: - "${MCP_WEBSOCKET_PORT:-5010}:${MCP_WEBSOCKET_PORT:-5010}" environment: - MCP_WEBSOCKET_PORT=${MCP_WEBSOCKET_PORT:-5010} restart: unless-stopped volumes: # Optional volume for persisting data if needed - ./logs:/app/logs ``` -------------------------------------------------------------------------------- /mcpServer/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "useUnknownInCatchVariables": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /mcpServer/smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. {} commandFunction: # A JS function that produces the CLI command based on the given config to start the MCP on stdio. |- (config) => ({ command: 'node', args: ['build/index.js'] }) exampleConfig: {} ``` -------------------------------------------------------------------------------- /mcpServer/Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile FROM node:lts-alpine WORKDIR /app # Copy package.json and package-lock.json COPY package*.json ./ # Install dependencies (ignore scripts if any issues with native builds) RUN npm install --ignore-scripts # Copy the rest of the application COPY . . # Build the TypeScript source RUN npm run build # Expose PORT if needed (optional) # Start the MCP server CMD ["node", "build/index.js"] ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "com.quaza.unitymcp", "version": "0.0.1", "displayName": "Unity MCP Integration", "description": "Integration between Model Context Protocol (MCP) and Unity, allowing AI assistants to understand and interact with Unity projects in real-time. Provides access to scene hierarchy, project settings, and code execution in the Unity Editor.", "unity": "2021.3", "dependencies": { }, "keywords": [ "ai", "Model Context Protocol", "Unity AI Tool", "Unity MCP" ], "author": { "name": "Shahzad", "url": "https://quazaai.com/" } } ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown ## [0.0.1] - 2023-11-11 ### First Release - Initial release of Unity MCP Integration - WebSocket-based communication between Unity Editor and MCP server - MCP tools implementation: - `get_editor_state` for retrieving Unity project information - `get_current_scene_info` for scene hierarchy details - `get_game_objects_info` for specific GameObject information - `execute_editor_command` for executing C# code in Unity Editor - `get_logs` for accessing Unity console logs - Debug window accessible from Window > MCP Debug menu - Connection status monitoring - Support for Unity 2021.3+ - Node.js MCP server (TypeScript implementation) - Comprehensive documentation - MIT License ``` -------------------------------------------------------------------------------- /mcpServer/package.json: -------------------------------------------------------------------------------- ```json { "name": "unity-mcp-server", "version": "0.1.0", "description": "MCP server for Unity integration", "type": "module", "bin": { "unity-mcp-server": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc", "start": "node build/index.js", "dev": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js" }, "dependencies": { "@modelcontextprotocol/sdk": "1.7.0", "ws": "^8.16.0", "diff": "^5.1.0", "minimatch": "^9.0.3", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.1" }, "devDependencies": { "@types/node": "^20.11.24", "@types/ws": "^8.5.10", "@types/diff": "^5.0.8", "@types/minimatch": "^5.1.2", "typescript": "^5.3.3" } } ``` -------------------------------------------------------------------------------- /Editor/Models/MCPEditorState.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using Newtonsoft.Json; namespace Plugins.GamePilot.Editor.MCP { [Serializable] public class MCPEditorState { [JsonProperty("activeGameObjects")] public string[] ActiveGameObjects { get; set; } = new string[0]; [JsonProperty("selectedObjects")] public string[] SelectedObjects { get; set; } = new string[0]; [JsonProperty("playModeState")] public string PlayModeState { get; set; } = "Stopped"; [JsonProperty("sceneHierarchy")] public List<MCPGameObjectInfo> SceneHierarchy { get; set; } = new List<MCPGameObjectInfo>(); // Removed ProjectStructure property [JsonProperty("timestamp")] public DateTime Timestamp { get; set; } = DateTime.UtcNow; // Enhanced project information properties [JsonProperty("renderPipeline")] public string RenderPipeline { get; set; } = "Unknown"; [JsonProperty("buildTarget")] public string BuildTarget { get; set; } = "Unknown"; [JsonProperty("projectName")] public string ProjectName { get; set; } = "Unknown"; [JsonProperty("graphicsDeviceType")] public string GraphicsDeviceType { get; set; } = "Unknown"; [JsonProperty("unityVersion")] public string UnityVersion { get; set; } = "Unknown"; [JsonProperty("currentSceneName")] public string CurrentSceneName { get; set; } = "Unknown"; [JsonProperty("currentScenePath")] public string CurrentScenePath { get; set; } = "Unknown"; [JsonProperty("availableMenuItems")] public List<string> AvailableMenuItems { get; set; } = new List<string>(); } [Serializable] public class MCPGameObjectInfo { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("path")] public string Path { get; set; } [JsonProperty("components")] public string[] Components { get; set; } = new string[0]; [JsonProperty("children")] public List<MCPGameObjectInfo> Children { get; set; } = new List<MCPGameObjectInfo>(); [JsonProperty("active")] public bool Active { get; set; } = true; [JsonProperty("layer")] public int Layer { get; set; } [JsonProperty("tag")] public string Tag { get; set; } } // Removed MCPProjectStructure class [Serializable] public class LogEntry { [JsonProperty("message")] public string Message { get; set; } [JsonProperty("stackTrace")] public string StackTrace { get; set; } [JsonProperty("type")] public UnityEngine.LogType Type { get; set; } [JsonProperty("timestamp")] public DateTime Timestamp { get; set; } } } ``` -------------------------------------------------------------------------------- /Editor/Models/MCPSceneInfo.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using Newtonsoft.Json; using UnityEngine; namespace Plugins.GamePilot.Editor.MCP { [Serializable] public class MCPSceneInfo { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("path")] public string Path { get; set; } [JsonProperty("isDirty")] public bool IsDirty { get; set; } [JsonProperty("rootCount")] public int RootCount { get; set; } [JsonProperty("rootObjects")] public List<MCPGameObjectReference> RootObjects { get; set; } = new List<MCPGameObjectReference>(); [JsonProperty("errorMessage")] public string ErrorMessage { get; set; } } [Serializable] public class MCPGameObjectReference { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("instanceID")] public int InstanceID { get; set; } [JsonProperty("path")] public string Path { get; set; } [JsonProperty("active")] public bool Active { get; set; } [JsonProperty("childCount")] public int ChildCount { get; set; } [JsonProperty("children")] public List<MCPGameObjectReference> Children { get; set; } } [Serializable] public class MCPGameObjectDetail { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("instanceID")] public int InstanceID { get; set; } [JsonProperty("path")] public string Path { get; set; } [JsonProperty("active")] public bool Active { get; set; } [JsonProperty("activeInHierarchy")] public bool ActiveInHierarchy { get; set; } [JsonProperty("tag")] public string Tag { get; set; } [JsonProperty("layer")] public int Layer { get; set; } [JsonProperty("layerName")] public string LayerName { get; set; } [JsonProperty("isStatic")] public bool IsStatic { get; set; } [JsonProperty("transform")] public MCPTransformInfo Transform { get; set; } [JsonProperty("components")] public List<MCPComponentInfo> Components { get; set; } [JsonProperty("children")] public List<MCPGameObjectDetail> Children { get; set; } } [Serializable] public class MCPTransformInfo { [JsonProperty("position")] public Vector3 Position { get; set; } [JsonProperty("rotation")] public Vector3 Rotation { get; set; } [JsonProperty("localPosition")] public Vector3 LocalPosition { get; set; } [JsonProperty("localRotation")] public Vector3 LocalRotation { get; set; } [JsonProperty("localScale")] public Vector3 LocalScale { get; set; } } [Serializable] public class MCPComponentInfo { [JsonProperty("type")] public string Type { get; set; } [JsonProperty("isEnabled")] public bool IsEnabled { get; set; } [JsonProperty("instanceID")] public int InstanceID { get; set; } } } ``` -------------------------------------------------------------------------------- /mcpServer/MCPSummary.md: -------------------------------------------------------------------------------- ```markdown <img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" class="logo" width="120"/> # --- # https://modelcontextprotocol.io/introduction https://modelcontextprotocol.io/quickstart/server https://modelcontextprotocol.io/quickstart/client https://modelcontextprotocol.io/docs/concepts/tools give me summary Here is a summary of the provided pages from the Model Context Protocol (MCP) documentation: --- ### **Introduction to MCP** - MCP is an open protocol that standardizes how applications provide context to large language models (LLMs), akin to how USB-C standardizes device connectivity[^1]. - It enables: - Pre-built integrations for LLMs to connect with data and tools. - Flexibility in switching between LLM providers. - Secure data handling within user infrastructure[^1]. - MCP follows a **client-server architecture**: - **MCP Hosts**: Applications like Claude Desktop or IDEs that access data via MCP. - **MCP Clients**: Protocol clients maintaining connections with servers. - **MCP Servers**: Lightweight programs exposing specific capabilities through MCP. - **Local Data Sources** and **Remote Services**: Securely accessed by servers[^1]. --- ### **Quickstart: Server** - This guide demonstrates building an MCP weather server that provides tools for fetching weather forecasts and alerts, connecting it to a host like Claude for Desktop[^2]. - **Core Concepts**: - **Resources**: File-like data accessible by clients. - **Tools**: Functions callable by LLMs with user approval. - **Prompts**: Templates for specific tasks[^2]. - Example implementation includes: - Setting up a Python environment with the `uv` tool. - Using the National Weather Service API to fetch weather data. - Exposing tools like `get-alerts` and `get-forecast` for integration with hosts like Claude for Desktop[^2]. - The server is tested by configuring Claude for Desktop to recognize and interact with it via commands[^2]. --- ### **Quickstart: Client** - This guide explains building an LLM-powered chatbot client capable of connecting to MCP servers[^3]. - Steps include: - Setting up a Python project using `uv`. - Storing API keys securely in environment files. - Creating a client class to manage server connections and process queries[^3]. - Key functionalities: - Connecting to MCP servers via Python or JavaScript scripts. - Handling queries by leveraging available server tools and integrating responses into natural language outputs using LLMs like Claude[^3]. --- ### **Concepts: Tools** - Tools are functions exposed by MCP servers, enabling LLMs to perform specific actions. These tools are defined programmatically and can be invoked securely with user consent[^1]. - Example use cases include automating workflows, accessing external APIs, or interacting with local/remote datasets through standardized interfaces[^1]. --- This documentation provides comprehensive guidance on building both servers and clients using MCP, enabling seamless integration between LLMs and various data sources or tools. <div style="text-align: center">⁂</div> [^1]: https://modelcontextprotocol.io/introduction [^2]: https://modelcontextprotocol.io/quickstart/server [^3]: https://modelcontextprotocol.io/quickstart/client [^4]: https://modelcontextprotocol.io/introduction [^5]: https://modelcontextprotocol.io/quickstart/server [^6]: https://modelcontextprotocol.io/quickstart/client [^7]: https://modelcontextprotocol.io/docs/concepts/tools ``` -------------------------------------------------------------------------------- /mcpServer/src/types.ts: -------------------------------------------------------------------------------- ```typescript // MCP Server Types for Unity Integration // Unity Editor State representation export interface UnityEditorState { activeGameObjects: any[]; selectedObjects: any[]; playModeState: string; sceneHierarchy: any; projectName?: string; unityVersion?: string; renderPipeline?: string; buildTarget?: string; graphicsDeviceType?: string; currentSceneName?: string; currentScenePath?: string; timestamp?: string; availableMenuItems?: string[]; } // Log entry from Unity export interface LogEntry { message: string; stackTrace: string; logType: string; timestamp: string; } // Scene info from Unity export interface SceneInfoMessage { type: 'sceneInfo'; data: { requestId: string; sceneInfo: any; timestamp: string; }; } // Game objects details from Unity export interface GameObjectsDetailsMessage { type: 'gameObjectsDetails'; data: { requestId: string; gameObjectDetails: any[]; count: number; timestamp: string; }; } // Message types from Unity to Server export interface EditorStateMessage { type: 'editorState'; data: UnityEditorState; } export interface CommandResultMessage { type: 'commandResult'; data: any; } export interface LogMessage { type: 'log'; data: LogEntry; } export interface PongMessage { type: 'pong'; data: { timestamp: number }; } // Message types from Server to Unity export interface ExecuteEditorCommandMessage { type: 'executeEditorCommand'; data: { code: string; }; } export interface HandshakeMessage { type: 'handshake'; data: { message: string }; } export interface PingMessage { type: 'ping'; data: { timestamp: number }; } export interface RequestEditorStateMessage { type: 'requestEditorState'; data: Record<string, never>; } export interface GetSceneInfoMessage { type: 'getSceneInfo'; data: { requestId: string; detailLevel: string; }; } export interface GetGameObjectsInfoMessage { type: 'getGameObjectsInfo'; data: { requestId: string; instanceIDs: number[]; detailLevel: string; }; } // Union type for all Unity messages export type UnityMessage = | EditorStateMessage | CommandResultMessage | LogMessage | PongMessage | SceneInfoMessage | GameObjectsDetailsMessage; // Union type for all Server messages export type ServerMessage = | ExecuteEditorCommandMessage | HandshakeMessage | PingMessage | RequestEditorStateMessage | GetSceneInfoMessage | GetGameObjectsInfoMessage; // Command result handling export interface CommandPromise { resolve: (data?: any) => void; reject: (reason?: any) => void; } export interface TreeEntry { name: string; type: 'file' | 'directory'; children?: TreeEntry[]; } export interface MCPSceneInfo { name: string; path: string; rootGameObjects: any[]; buildIndex: number; isDirty: boolean; isLoaded: boolean; } export interface MCPTransformInfo { position: { x: number, y: number, z: number }; rotation: { x: number, y: number, z: number }; localPosition: { x: number, y: number, z: number }; localRotation: { x: number, y: number, z: number }; localScale: { x: number, y: number, z: number }; } export interface MCPComponentInfo { type: string; isEnabled: boolean; instanceID: number; } export interface MCPGameObjectDetail { name: string; instanceID: number; path: string; active: boolean; activeInHierarchy: boolean; tag: string; layer: number; layerName: string; isStatic: boolean; transform: MCPTransformInfo; components: MCPComponentInfo[]; } export enum SceneInfoDetail { RootObjectsOnly = 'RootObjectsOnly', FullHierarchy = 'FullHierarchy' } export enum GameObjectInfoDetail { BasicInfo = 'BasicInfo', IncludeComponents = 'IncludeComponents', IncludeChildren = 'IncludeChildren', IncludeComponentsAndChildren = 'IncludeComponentsAndChildren' } ``` -------------------------------------------------------------------------------- /Editor/MCPManager.cs: -------------------------------------------------------------------------------- ```csharp using System; using UnityEditor; using UnityEngine; namespace Plugins.GamePilot.Editor.MCP { [InitializeOnLoad] public class MCPManager { private static readonly string ComponentName = "MCPManager"; private static MCPConnectionManager connectionManager; private static MCPMessageHandler messageHandler; private static MCPDataCollector dataCollector; private static MCPMessageSender messageSender; private static bool isInitialized = false; public static bool IsInitialized => isInitialized; private static bool autoReconnect = false; // Constructor called on editor startup due to [InitializeOnLoad] static MCPManager() { // Initialize logger for this component MCPLogger.InitializeComponent(ComponentName); EditorApplication.delayCall += Initialize; } public static void Initialize() { if (isInitialized) return; MCPLogger.Log(ComponentName, "Initializing Model Context Protocol system..."); try { // Create components dataCollector = new MCPDataCollector(); connectionManager = new MCPConnectionManager(); messageSender = new MCPMessageSender(connectionManager); messageHandler = new MCPMessageHandler(dataCollector, messageSender); // Hook up events connectionManager.OnMessageReceived += messageHandler.HandleMessage; connectionManager.OnConnected += OnConnected; connectionManager.OnDisconnected += OnDisconnected; connectionManager.OnError += OnError; // Start connection connectionManager.Connect(); // Register update for connection checking only EditorApplication.update += Update; isInitialized = true; MCPLogger.Log(ComponentName, "Model Context Protocol system initialized successfully"); } catch (Exception ex) { MCPLogger.LogException(ComponentName, ex); Debug.LogError($"[MCP] Failed to initialize MCP system: {ex.Message}\n{ex.StackTrace}"); } } private static void OnConnected() { try { MCPLogger.Log(ComponentName, "Connected to MCP server"); } catch (Exception ex) { MCPLogger.LogException(ComponentName, ex); } } private static void OnDisconnected() { try { MCPLogger.Log(ComponentName, "Disconnected from MCP server"); } catch (Exception ex) { MCPLogger.LogException(ComponentName, ex); } } private static void OnError(string errorMessage) { MCPLogger.LogError(ComponentName, $"Connection error: {errorMessage}"); } private static void Update() { try { // Check if connection manager needs to reconnect if (autoReconnect) { connectionManager?.CheckConnection(); } } catch (Exception ex) { MCPLogger.LogException(ComponentName, ex); } } // Public methods for manual control public static void RetryConnection() { connectionManager?.Reconnect(); } public static void EnableAutoReconnect(bool enable) { autoReconnect = enable; MCPLogger.Log(ComponentName, $"Auto reconnect set to: {enable}"); } public static bool IsConnected => connectionManager?.IsConnected ?? false; public static void Shutdown() { if (!isInitialized) return; try { MCPLogger.Log(ComponentName, "Shutting down MCP system"); // Unregister update callbacks EditorApplication.update -= Update; // Disconnect connectionManager?.Disconnect(); // Cleanup dataCollector?.Dispose(); isInitialized = false; MCPLogger.Log(ComponentName, "System shutdown completed"); } catch (Exception ex) { MCPLogger.LogException(ComponentName, ex); } } } } ``` -------------------------------------------------------------------------------- /Editor/MCPLogger.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using UnityEngine; namespace Plugins.GamePilot.Editor.MCP { /// <summary> /// Centralized logging system for MCP components that respects component-level logging settings. /// </summary> public static class MCPLogger { // Dictionary to track enabled status for each component private static readonly Dictionary<string, bool> componentLoggingEnabled = new Dictionary<string, bool>(); // Default log state (off by default) private static bool globalLoggingEnabled = false; /// <summary> /// Enable or disable logging globally for all components /// </summary> public static bool GlobalLoggingEnabled { get => globalLoggingEnabled; set => globalLoggingEnabled = value; } /// <summary> /// Initialize a component for logging /// </summary> /// <param name="componentName">Name of the component</param> /// <param name="enabledByDefault">Whether logging should be enabled by default</param> public static void InitializeComponent(string componentName, bool enabledByDefault = false) { if (!componentLoggingEnabled.ContainsKey(componentName)) { componentLoggingEnabled[componentName] = enabledByDefault; } } /// <summary> /// Set logging state for a specific component /// </summary> /// <param name="componentName">Name of the component</param> /// <param name="enabled">Whether logging should be enabled</param> public static void SetComponentLoggingEnabled(string componentName, bool enabled) { // Make sure component exists before setting state if (!componentLoggingEnabled.ContainsKey(componentName)) { InitializeComponent(componentName, false); } componentLoggingEnabled[componentName] = enabled; } /// <summary> /// Check if logging is enabled for a component /// </summary> /// <param name="componentName">Name of the component</param> /// <returns>True if logging is enabled</returns> public static bool IsLoggingEnabled(string componentName) { // If global logging is disabled, nothing gets logged if (!globalLoggingEnabled) return false; // If component isn't registered, assume it's disabled if (!componentLoggingEnabled.ContainsKey(componentName)) { InitializeComponent(componentName, false); return false; } // Return component-specific setting return componentLoggingEnabled[componentName]; } /// <summary> /// Log a message if logging is enabled for the component /// </summary> /// <param name="componentName">Name of the component</param> /// <param name="message">Message to log</param> public static void Log(string componentName, string message) { if (IsLoggingEnabled(componentName)) { Debug.Log($"[MCP] [{componentName}] {message}"); } } /// <summary> /// Log a warning if logging is enabled for the component /// </summary> /// <param name="componentName">Name of the component</param> /// <param name="message">Message to log</param> public static void LogWarning(string componentName, string message) { if (IsLoggingEnabled(componentName)) { Debug.LogWarning($"[MCP] [{componentName}] {message}"); } } /// <summary> /// Log an error if logging is enabled for the component /// </summary> /// <param name="componentName">Name of the component</param> /// <param name="message">Message to log</param> public static void LogError(string componentName, string message) { if (IsLoggingEnabled(componentName)) { Debug.LogError($"[MCP] [{componentName}] {message}"); } } /// <summary> /// Log an exception if logging is enabled for the component /// </summary> /// <param name="componentName">Name of the component</param> /// <param name="ex">Exception to log</param> public static void LogException(string componentName, Exception ex) { if (IsLoggingEnabled(componentName)) { Debug.LogError($"[MCP] [{componentName}] Exception: {ex.Message}\n{ex.StackTrace}"); } } /// <summary> /// Get all registered components /// </summary> public static IEnumerable<string> GetRegisteredComponents() { return componentLoggingEnabled.Keys; } /// <summary> /// Get logging state for a component /// </summary> public static bool GetComponentLoggingEnabled(string componentName) { if (!componentLoggingEnabled.ContainsKey(componentName)) return false; return componentLoggingEnabled[componentName]; } } } ``` -------------------------------------------------------------------------------- /mcpServer/src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { WebSocketHandler } from './websocketHandler.js'; import { registerTools } from './toolDefinitions.js'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; class UnityMCPServer { private server: Server; private wsHandler: WebSocketHandler; constructor() { // Initialize MCP Server this.server = new Server( { name: 'unity-mcp-server', version: '0.2.0' }, { capabilities: { tools: {} } } ); // Setup project paths and websocket const wsPort = parseInt(process.env.MCP_WEBSOCKET_PORT || '5010'); const projectRootPath = this.setupProjectPaths(); // Initialize WebSocket Handler for Unity communication this.wsHandler = new WebSocketHandler(wsPort); // Register MCP tools registerTools(this.server, this.wsHandler); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); this.setupShutdownHandlers(); } private setupProjectPaths(): string { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); console.error(`[Unity MCP] Server starting from directory: ${__dirname}`); // Get the project root path (parent of Assets) let projectRootPath = process.env.UNITY_PROJECT_PATH || this.determineUnityProjectPath(__dirname); projectRootPath = path.normalize(projectRootPath.replace(/["']/g, '')); // Make sure path ends with a directory separator if (!projectRootPath.endsWith(path.sep)) { projectRootPath += path.sep; } // Create the full path to the Assets folder const projectPath = path.join(projectRootPath, 'Assets') + path.sep; this.setupEnvironmentPath(projectRootPath, projectPath); return projectRootPath; } private setupEnvironmentPath(projectRootPath: string, projectPath: string): void { try { if (fs.existsSync(projectPath)) { console.error(`[Unity MCP] Using project path: ${projectPath}`); process.env.UNITY_PROJECT_PATH = projectPath; } else { console.error(`[Unity MCP] WARNING: Assets folder not found at ${projectPath}`); console.error(`[Unity MCP] Using project root instead: ${projectRootPath}`); process.env.UNITY_PROJECT_PATH = projectRootPath; } } catch (error) { console.error(`[Unity MCP] Error checking project path: ${error}`); process.env.UNITY_PROJECT_PATH = process.cwd(); } } private setupShutdownHandlers(): void { const cleanupHandler = async () => { await this.cleanup(); process.exit(0); }; process.on('SIGINT', cleanupHandler); process.on('SIGTERM', cleanupHandler); } /** * Determine the Unity project path based on the script location */ private determineUnityProjectPath(scriptDir: string): string { scriptDir = path.normalize(scriptDir); console.error(`[Unity MCP] Script directory: ${scriptDir}`); // Case 1: Installed in Assets folder const assetsMatch = /^(.+?[\/\\]Assets)[\/\\].*$/i.exec(scriptDir); if (assetsMatch) { const projectRoot = path.dirname(assetsMatch[1]); console.error(`[Unity MCP] Detected installation in Assets folder: ${projectRoot}`); return projectRoot; } // Case 2: Installed via Package Manager const libraryMatch = /^(.+?[\/\\]Library)[\/\\]PackageCache[\/\\].*$/i.exec(scriptDir); if (libraryMatch) { const projectRoot = path.dirname(libraryMatch[1]); console.error(`[Unity MCP] Detected installation via Package Manager: ${projectRoot}`); const assetsPath = path.join(projectRoot, 'Assets'); if (fs.existsSync(assetsPath)) { return projectRoot; } } // Case 3: Check parent directories for (const dir of this.getParentDirectories(scriptDir)) { // Check if this directory is "UnityMCP" if (path.basename(dir) === 'UnityMCP') { console.error(`[Unity MCP] Found UnityMCP directory at: ${dir}`); return dir; } // Check if this directory contains an Assets folder const assetsDir = path.join(dir, 'Assets'); try { if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) { console.error(`[Unity MCP] Found Unity project at: ${dir}`); return dir; } } catch (e) { // Ignore errors checking directories } } // Fallback console.error('[Unity MCP] Could not detect Unity project directory. Using current directory.'); return process.cwd(); } private getParentDirectories(filePath: string): string[] { const result: string[] = []; const dirs = filePath.split(path.sep); for (let i = 1; i <= dirs.length; i++) { result.push(dirs.slice(0, i).join(path.sep)); } return result; } private async cleanup() { console.error('Cleaning up resources...'); await this.wsHandler.close(); await this.server.close(); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('[Unity MCP] Server running and ready to accept connections'); console.error('[Unity MCP] WebSocket server listening on port', this.wsHandler.port); } } // Start the server const server = new UnityMCPServer(); server.run().catch(err => { console.error('Fatal error in MCP server:', err); process.exit(1); }); ``` -------------------------------------------------------------------------------- /Editor/MCPCodeExecutor.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Linq; using Microsoft.CSharp; using UnityEngine; using UnityEditor; namespace Plugins.GamePilot.Editor.MCP { public class MCPCodeExecutor { private readonly List<string> logs = new List<string>(); private readonly List<string> errors = new List<string>(); private readonly List<string> warnings = new List<string>(); public object ExecuteCode(string code) { logs.Clear(); errors.Clear(); warnings.Clear(); // Add log handler to capture output during execution Application.logMessageReceived += LogHandler; try { return ExecuteCommand(code); } catch (Exception ex) { string errorMessage = $"Code execution failed: {ex.Message}\n{ex.StackTrace}"; Debug.LogError(errorMessage); errors.Add(errorMessage); return null; } finally { Application.logMessageReceived -= LogHandler; GC.Collect(); GC.WaitForPendingFinalizers(); } } private object ExecuteCommand(string code) { // The code should define a class called "McpScript" with a static method "Execute" // Less restrictive on what the code can contain (namespaces, classes, etc.) using (var provider = new CSharpCodeProvider()) { var options = new CompilerParameters { GenerateInMemory = true, IncludeDebugInformation = true }; // Add essential references AddEssentialReferences(options); // Compile the code as provided - with no wrapping var results = provider.CompileAssemblyFromSource(options, code); if (results.Errors.HasErrors) { var errorMessages = new List<string>(); foreach (CompilerError error in results.Errors) { errorMessages.Add($"Line {error.Line}: {error.ErrorText}"); } throw new Exception("Compilation failed: " + string.Join("\n", errorMessages)); } // Get the compiled assembly and execute the code via the McpScript.Execute method var assembly = results.CompiledAssembly; var type = assembly.GetType("McpScript"); if (type == null) { throw new Exception("Could not find McpScript class in compiled assembly. Make sure your code defines a public class named 'McpScript'."); } var method = type.GetMethod("Execute"); if (method == null) { throw new Exception("Could not find Execute method in McpScript class. Make sure your code includes a public static method named 'Execute'."); } return method.Invoke(null, null); } } private void AddEssentialReferences(CompilerParameters options) { // Only add the most essential references to avoid conflicts try { // Core Unity and .NET references options.ReferencedAssemblies.Add(typeof(UnityEngine.Object).Assembly.Location); // UnityEngine options.ReferencedAssemblies.Add(typeof(UnityEditor.Editor).Assembly.Location); // UnityEditor options.ReferencedAssemblies.Add(typeof(System.Object).Assembly.Location); // mscorlib // Add System.Core for LINQ var systemCore = AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(a => a.GetName().Name == "System.Core"); if (systemCore != null && !string.IsNullOrEmpty(systemCore.Location)) { options.ReferencedAssemblies.Add(systemCore.Location); } // Add netstandard reference var netStandardAssembly = AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(a => a.GetName().Name == "netstandard"); if (netStandardAssembly != null && !string.IsNullOrEmpty(netStandardAssembly.Location)) { options.ReferencedAssemblies.Add(netStandardAssembly.Location); } // Add essential Unity modules AddUnityModule(options, "UnityEngine.CoreModule"); AddUnityModule(options, "UnityEngine.PhysicsModule"); AddUnityModule(options, "UnityEngine.UIModule"); AddUnityModule(options, "UnityEngine.InputModule"); AddUnityModule(options, "UnityEngine.AnimationModule"); AddUnityModule(options, "UnityEngine.IMGUIModule"); } catch (Exception ex) { Debug.LogWarning($"Error adding assembly references: {ex.Message}"); } } private void AddUnityModule(CompilerParameters options, string moduleName) { try { var assembly = AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(a => a.GetName().Name == moduleName); if (assembly != null && !string.IsNullOrEmpty(assembly.Location) && !options.ReferencedAssemblies.Contains(assembly.Location)) { options.ReferencedAssemblies.Add(assembly.Location); } } catch (Exception ex) { Debug.LogWarning($"Failed to add Unity module {moduleName}: {ex.Message}"); } } private void LogHandler(string message, string stackTrace, LogType type) { switch (type) { case LogType.Log: logs.Add(message); break; case LogType.Warning: warnings.Add(message); break; case LogType.Error: case LogType.Exception: case LogType.Assert: errors.Add($"{message}\n{stackTrace}"); break; } } public string[] GetLogs() => logs.ToArray(); public string[] GetErrors() => errors.ToArray(); public string[] GetWarnings() => warnings.ToArray(); } } ``` -------------------------------------------------------------------------------- /Editor/MCPMessageSender.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json; using UnityEngine; using System.Linq; namespace Plugins.GamePilot.Editor.MCP { public class MCPMessageSender { private readonly MCPConnectionManager connectionManager; public MCPMessageSender(MCPConnectionManager connectionManager) { this.connectionManager = connectionManager ?? throw new ArgumentNullException(nameof(connectionManager)); } public async Task SendEditorStateAsync(MCPEditorState state) { if (state == null) return; if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "editorState", data = state }, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending editor state: {ex.Message}"); } } public async Task SendLogEntryAsync(LogEntry logEntry) { if (logEntry == null) return; if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "log", data = new { message = logEntry.Message, stackTrace = logEntry.StackTrace, logType = logEntry.Type.ToString(), timestamp = logEntry.Timestamp } }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending log entry: {ex.Message}"); } } public async Task SendCommandResultAsync(string commandId, object result, IEnumerable<string> logs, IEnumerable<string> errors, IEnumerable<string> warnings) { if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "commandResult", data = new { commandId, result = result, logs = logs ?? Array.Empty<string>(), errors = errors ?? Array.Empty<string>(), warnings = warnings ?? Array.Empty<string>(), executionSuccess = errors == null || !errors.GetEnumerator().MoveNext() } }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending command result: {ex.Message}"); } } public async Task SendErrorMessageAsync(string errorCode, string errorMessage) { if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "error", data = new { code = errorCode, message = errorMessage, timestamp = DateTime.UtcNow } }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending error message: {ex.Message}"); } } public async Task SendGetLogsResponseAsync(string requestId, LogEntry[] logs) { if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "logsResponse", data = new { requestId, logs, timestamp = DateTime.UtcNow } }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending logs response: {ex.Message}"); } } public async Task SendSceneInfoAsync(string requestId, MCPSceneInfo sceneInfo) { if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "sceneInfo", data = new { requestId, sceneInfo, timestamp = DateTime.UtcNow } }, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending scene info: {ex.Message}"); } } public async Task SendGameObjectsDetailsAsync(string requestId, List<MCPGameObjectDetail> gameObjectDetails) { if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "gameObjectsDetails", data = new { requestId, gameObjectDetails, count = gameObjectDetails?.Count ?? 0, timestamp = DateTime.UtcNow } }, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending game objects details: {ex.Message}"); } } // Add new method to send pong message back to the server public async Task SendPongAsync() { if (!connectionManager.IsConnected) return; try { var message = JsonConvert.SerializeObject(new { type = "pong", data = new { timestamp = DateTime.UtcNow.Ticks } }); await connectionManager.SendMessageAsync(message); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending pong message: {ex.Message}"); } } // Add new method to handle single GameObject details request public async Task SendGameObjectDetailsAsync(string requestId, GameObject gameObject) { if (!connectionManager.IsConnected || gameObject == null) return; try { // Create a list with a single game object detail var gameObjectDetail = new MCPGameObjectDetail { Name = gameObject.name, InstanceID = gameObject.GetInstanceID(), Path = GetGameObjectPath(gameObject), Active = gameObject.activeSelf, ActiveInHierarchy = gameObject.activeInHierarchy, Tag = gameObject.tag, Layer = gameObject.layer, LayerName = LayerMask.LayerToName(gameObject.layer), IsStatic = gameObject.isStatic, Transform = new MCPTransformInfo { Position = gameObject.transform.position, Rotation = gameObject.transform.rotation.eulerAngles, LocalPosition = gameObject.transform.localPosition, LocalRotation = gameObject.transform.localRotation.eulerAngles, LocalScale = gameObject.transform.localScale }, Components = gameObject.GetComponents<Component>() .Where(c => c != null) .Select(c => new MCPComponentInfo { Type = c.GetType().Name, IsEnabled = GetComponentEnabled(c), InstanceID = c.GetInstanceID() }) .ToList() }; var details = new List<MCPGameObjectDetail> { gameObjectDetail }; // Use the existing method to send the details await SendGameObjectsDetailsAsync(requestId, details); } catch (Exception ex) { Debug.LogError($"[MCP] Error sending game object details: {ex.Message}"); await SendErrorMessageAsync("GAME_OBJECT_DETAIL_ERROR", ex.Message); } } private string GetGameObjectPath(GameObject obj) { if (obj == null) return string.Empty; string path = obj.name; var parent = obj.transform.parent; while (parent != null) { path = parent.name + "/" + path; parent = parent.parent; } return path; } private bool GetComponentEnabled(Component component) { // Try to check if component is enabled (for components that support it) try { if (component is Behaviour behaviour) return behaviour.enabled; if (component is Renderer renderer) return renderer.enabled; if (component is Collider collider) return collider.enabled; } catch { // Ignore any exceptions } // Default to true for components that don't have an enabled property return true; } } } ``` -------------------------------------------------------------------------------- /Editor/MCPConnectionManager.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; namespace Plugins.GamePilot.Editor.MCP { public class MCPConnectionManager { private static readonly string ComponentName = "MCPConnectionManager"; private ClientWebSocket webSocket; private Uri serverUri = new Uri("ws://localhost:5010"); // Changed to allow changing private readonly CancellationTokenSource cts = new CancellationTokenSource(); private bool isConnected = false; private float reconnectTimer = 0f; private readonly float reconnectInterval = 5f; private string lastErrorMessage = string.Empty; // Statistics private int messagesSent = 0; private int messagesReceived = 0; private int reconnectAttempts = 0; // Events public event Action<string> OnMessageReceived; public event Action OnConnected; public event Action OnDisconnected; public event Action<string> OnError; // Properly track the connection state using the WebSocket state and our own flag public bool IsConnected => isConnected && webSocket?.State == WebSocketState.Open; public string LastErrorMessage => lastErrorMessage; public int MessagesSent => messagesSent; public int MessagesReceived => messagesReceived; public int ReconnectAttempts => reconnectAttempts; public Uri ServerUri { get => serverUri; set => serverUri = value; } public MCPConnectionManager() { MCPLogger.InitializeComponent(ComponentName); webSocket = new ClientWebSocket(); webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); } public async void Connect() { // Double check connections that look open but may be stale if (webSocket != null && webSocket.State == WebSocketState.Open) { try { // Try to send a ping to verify connection is truly active bool connectionIsActive = await TestConnection(); if (connectionIsActive) { MCPLogger.Log(ComponentName, "WebSocket already connected and active"); return; } else { MCPLogger.Log(ComponentName, "WebSocket appears open but is stale, reconnecting..."); // Fall through to reconnection logic } } catch (Exception) { MCPLogger.Log(ComponentName, "WebSocket appears open but failed ping test, reconnecting..."); // Fall through to reconnection logic } } else if (webSocket != null && webSocket.State == WebSocketState.Connecting) { MCPLogger.Log(ComponentName, "WebSocket is already connecting"); return; } // Clean up any existing socket if (webSocket != null) { try { if (webSocket.State == WebSocketState.Open) { await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnecting", CancellationToken.None); } webSocket.Dispose(); } catch (Exception ex) { MCPLogger.LogWarning(ComponentName, $"Error cleaning up WebSocket: {ex.Message}"); } } webSocket = new ClientWebSocket(); webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); try { MCPLogger.Log(ComponentName, $"Connecting to MCP Server at {serverUri}"); var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token); await webSocket.ConnectAsync(serverUri, linkedCts.Token); isConnected = true; MCPLogger.Log(ComponentName, "Successfully connected to MCP Server"); OnConnected?.Invoke(); StartReceiving(); } catch (OperationCanceledException) { lastErrorMessage = "Connection attempt timed out"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); isConnected = false; OnDisconnected?.Invoke(); } catch (WebSocketException we) { lastErrorMessage = $"WebSocket error: {we.Message}"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); isConnected = false; OnDisconnected?.Invoke(); } catch (Exception e) { lastErrorMessage = $"Failed to connect: {e.Message}"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); isConnected = false; OnDisconnected?.Invoke(); } } // Test if connection is still valid with a simple ping private async Task<bool> TestConnection() { try { // Simple ping test - send a 1-byte message byte[] pingData = new byte[1] { 0 }; await webSocket.SendAsync( new ArraySegment<byte>(pingData), WebSocketMessageType.Binary, true, CancellationToken.None); return true; } catch { return false; } } public void Reconnect() { reconnectAttempts++; MCPLogger.Log(ComponentName, "Manually reconnecting..."); reconnectTimer = 0; Connect(); } public void CheckConnection() { if (!isConnected || webSocket?.State != WebSocketState.Open) { reconnectTimer += UnityEngine.Time.deltaTime; if (reconnectTimer >= reconnectInterval) { reconnectAttempts++; MCPLogger.Log(ComponentName, "Attempting reconnection..."); reconnectTimer = 0f; Connect(); } } } private async void StartReceiving() { var buffer = new byte[8192]; // 8KB buffer try { while (webSocket.State == WebSocketState.Open && !cts.IsCancellationRequested) { var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token); if (result.MessageType == WebSocketMessageType.Text) { messagesReceived++; var message = Encoding.UTF8.GetString(buffer, 0, result.Count); MCPLogger.Log(ComponentName, $"Received message: {message.Substring(0, Math.Min(100, message.Length))}..."); OnMessageReceived?.Invoke(message); } else if (result.MessageType == WebSocketMessageType.Close) { MCPLogger.Log(ComponentName, "Server requested connection close"); isConnected = false; OnDisconnected?.Invoke(); break; } } } catch (OperationCanceledException) { // Normal cancellation, don't log as error MCPLogger.Log(ComponentName, "WebSocket receiving was canceled"); } catch (WebSocketException wsEx) { if (!cts.IsCancellationRequested) { lastErrorMessage = $"WebSocket error: {wsEx.Message}"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); } } catch (Exception e) { if (!cts.IsCancellationRequested) { lastErrorMessage = $"Connection error: {e.Message}"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); } } finally { if (isConnected) { isConnected = false; OnDisconnected?.Invoke(); } } } public async Task SendMessageAsync(string message) { if (!isConnected || webSocket?.State != WebSocketState.Open) { MCPLogger.LogWarning(ComponentName, "Cannot send message: not connected"); return; } try { var buffer = Encoding.UTF8.GetBytes(message); await webSocket.SendAsync( new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, cts.Token); messagesSent++; MCPLogger.Log(ComponentName, $"Sent message: {message.Substring(0, Math.Min(100, message.Length))}..."); } catch (OperationCanceledException) { // Normal cancellation, don't log as error MCPLogger.Log(ComponentName, "Send operation was canceled"); } catch (WebSocketException wsEx) { lastErrorMessage = $"WebSocket send error: {wsEx.Message}"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); // Connection might be broken, mark as disconnected isConnected = false; OnDisconnected?.Invoke(); } catch (Exception e) { lastErrorMessage = $"Failed to send message: {e.Message}"; MCPLogger.LogError(ComponentName, lastErrorMessage); OnError?.Invoke(lastErrorMessage); // Connection might be broken, mark as disconnected isConnected = false; OnDisconnected?.Invoke(); } } public void Disconnect() { try { MCPLogger.Log(ComponentName, "Disconnecting from server"); // Cancel any pending operations if (!cts.IsCancellationRequested) { cts.Cancel(); } if (webSocket != null && webSocket.State == WebSocketState.Open) { // Begin graceful close var closeTask = webSocket.CloseAsync( WebSocketCloseStatus.NormalClosure, "Client disconnecting", CancellationToken.None); // Give it a moment to close gracefully Task.WaitAny(new[] { closeTask }, 1000); } // Dispose resources if (webSocket != null) { webSocket.Dispose(); webSocket = null; } } catch (Exception e) { MCPLogger.LogWarning(ComponentName, $"Error during disconnect: {e.Message}"); } finally { isConnected = false; OnDisconnected?.Invoke(); } } ~MCPConnectionManager() { // Ensure resources are cleaned up Disconnect(); } } } ``` -------------------------------------------------------------------------------- /mcpServer/src/websocketHandler.ts: -------------------------------------------------------------------------------- ```typescript import { WebSocketServer, WebSocket } from 'ws'; import { UnityMessage, UnityEditorState, LogEntry, CommandPromise } from './types.js'; export class WebSocketHandler { private wsServer!: WebSocketServer; // Add definite assignment assertion private _port: number; // Make this a private field, not readonly private unityConnection: WebSocket | null = null; private editorState: UnityEditorState = { activeGameObjects: [], selectedObjects: [], playModeState: 'Stopped', sceneHierarchy: {} }; private logBuffer: LogEntry[] = []; private readonly maxLogBufferSize = 1000; private commandResultPromise: CommandPromise | null = null; private commandStartTime: number | null = null; private lastHeartbeat: number = 0; private connectionEstablished: boolean = false; private pendingRequests: Record<string, { resolve: (data?: any) => void; reject: (reason?: any) => void; type: string; }> = {}; constructor(port: number = 5010) { this._port = port; // Store in private field this.initializeWebSocketServer(port); } // Add a getter to expose port as readonly public get port(): number { return this._port; } private initializeWebSocketServer(port: number): void { try { this.wsServer = new WebSocketServer({ port }); this.setupWebSocketServer(); console.error(`[Unity MCP] WebSocket server started on port ${this._port}`); } catch (error) { console.error(`[Unity MCP] ERROR starting WebSocket server on port ${port}:`, error); this.tryAlternativePort(port); } } private tryAlternativePort(originalPort: number): void { try { const alternativePort = originalPort + 1; console.error(`[Unity MCP] Trying alternative port ${alternativePort}...`); this._port = alternativePort; // Update the private field instead of readonly property this.wsServer = new WebSocketServer({ port: alternativePort }); this.setupWebSocketServer(); console.error(`[Unity MCP] WebSocket server started on alternative port ${this._port}`); } catch (secondError) { console.error(`[Unity MCP] FATAL: Could not start WebSocket server:`, secondError); throw new Error(`Failed to start WebSocket server: ${secondError}`); } } private setupWebSocketServer() { console.error(`[Unity MCP] WebSocket server starting on port ${this._port}`); this.wsServer.on('listening', () => { console.error('[Unity MCP] WebSocket server is listening for connections'); }); this.wsServer.on('error', (error) => { console.error('[Unity MCP] WebSocket server error:', error); }); this.wsServer.on('connection', this.handleNewConnection.bind(this)); } private handleNewConnection(ws: WebSocket): void { console.error('[Unity MCP] Unity Editor connected'); this.unityConnection = ws; this.connectionEstablished = true; this.lastHeartbeat = Date.now(); // Send a simple handshake message to verify connection this.sendHandshake(); ws.on('message', (data) => this.handleIncomingMessage(data)); ws.on('error', (error) => { console.error('[Unity MCP] WebSocket error:', error); this.connectionEstablished = false; }); ws.on('close', () => { console.error('[Unity MCP] Unity Editor disconnected'); this.unityConnection = null; this.connectionEstablished = false; }); // Keep the automatic heartbeat for internal connection validation const pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { this.sendPing(); } else { clearInterval(pingInterval); } }, 30000); // Send heartbeat every 30 seconds } private handleIncomingMessage(data: any): void { try { // Update heartbeat on any message this.lastHeartbeat = Date.now(); const message = JSON.parse(data.toString()); console.error('[Unity MCP] Received message type:', message.type); this.handleUnityMessage(message); } catch (error) { console.error('[Unity MCP] Error handling message:', error); } } private sendHandshake() { this.sendToUnity({ type: 'handshake', data: { message: 'MCP Server Connected' } }); } // Renamed from sendHeartbeat to sendPing for consistency with protocol private sendPing() { this.sendToUnity({ type: "ping", data: { timestamp: Date.now() } }); } // Helper method to safely send messages to Unity private sendToUnity(message: any): void { try { if (this.unityConnection && this.unityConnection.readyState === WebSocket.OPEN) { this.unityConnection.send(JSON.stringify(message)); } } catch (error) { console.error(`[Unity MCP] Error sending message: ${error}`); this.connectionEstablished = false; } } private handleUnityMessage(message: UnityMessage) { switch (message.type) { case 'editorState': this.editorState = message.data; break; case 'commandResult': // Resolve the pending command result promise if (this.commandResultPromise) { this.commandResultPromise.resolve(message.data); this.commandResultPromise = null; this.commandStartTime = null; } break; case 'log': this.addLogEntry(message.data); break; case 'pong': // Update heartbeat reception timestamp when receiving pong this.lastHeartbeat = Date.now(); this.connectionEstablished = true; break; case 'sceneInfo': case 'gameObjectsDetails': this.handleRequestResponse(message); break; default: console.error('[Unity MCP] Unknown message type:', message); break; } } private handleRequestResponse(message: UnityMessage): void { const requestId = message.data?.requestId; if (requestId && this.pendingRequests[requestId]) { // Fix the type issue by checking the property exists first if (this.pendingRequests[requestId]) { this.pendingRequests[requestId].resolve(message.data); delete this.pendingRequests[requestId]; } } } private addLogEntry(logEntry: LogEntry) { // Add to buffer, removing oldest if at capacity this.logBuffer.push(logEntry); if (this.logBuffer.length > this.maxLogBufferSize) { this.logBuffer.shift(); } } public async executeEditorCommand(code: string, timeoutMs: number = 5000): Promise<any> { if (!this.isConnected()) { throw new Error('Unity Editor is not connected'); } try { // Start timing the command execution this.commandStartTime = Date.now(); // Send the command to Unity this.sendToUnity({ type: 'executeEditorCommand', data: { code } }); // Wait for result with timeout return await Promise.race([ new Promise((resolve, reject) => { this.commandResultPromise = { resolve, reject }; }), new Promise((_, reject) => setTimeout(() => reject(new Error( `Command execution timed out after ${timeoutMs/1000} seconds` )), timeoutMs) ) ]); } catch (error) { // Reset command promise state if there's an error this.commandResultPromise = null; this.commandStartTime = null; throw error; } } // Return the current editor state - only used by tools, doesn't request updates public getEditorState(): UnityEditorState { return this.editorState; } public getLogEntries(options: { types?: string[], count?: number, fields?: string[], messageContains?: string, stackTraceContains?: string, timestampAfter?: string, timestampBefore?: string } = {}): Partial<LogEntry>[] { const { types, count = 100, fields, messageContains, stackTraceContains, timestampAfter, timestampBefore } = options; // Apply all filters let filteredLogs = this.filterLogs(types, messageContains, stackTraceContains, timestampAfter, timestampBefore); // Apply count limit filteredLogs = filteredLogs.slice(-count); // Apply field selection if specified if (fields?.length) { return this.selectFields(filteredLogs, fields); } return filteredLogs; } private filterLogs(types?: string[], messageContains?: string, stackTraceContains?: string, timestampAfter?: string, timestampBefore?: string): LogEntry[] { return this.logBuffer.filter(log => { // Type filter if (types && !types.includes(log.logType)) return false; // Message content filter if (messageContains && !log.message.includes(messageContains)) return false; // Stack trace content filter if (stackTraceContains && !log.stackTrace.includes(stackTraceContains)) return false; // Timestamp filters if (timestampAfter && new Date(log.timestamp) < new Date(timestampAfter)) return false; if (timestampBefore && new Date(log.timestamp) > new Date(timestampBefore)) return false; return true; }); } private selectFields(logs: LogEntry[], fields: string[]): Partial<LogEntry>[] { return logs.map(log => { const selectedFields: Partial<LogEntry> = {}; fields.forEach(field => { if (field in log) { selectedFields[field as keyof LogEntry] = log[field as keyof LogEntry]; } }); return selectedFields; }); } public isConnected(): boolean { // More robust connection check if (this.unityConnection === null || this.unityConnection.readyState !== WebSocket.OPEN) { return false; } // Check if we've received messages from Unity recently if (!this.connectionEstablished) { return false; } // Check if we've received a heartbeat in the last 60 seconds const heartbeatTimeout = 60000; // 60 seconds if (Date.now() - this.lastHeartbeat > heartbeatTimeout) { console.error('[Unity MCP] Connection may be stale - no recent communication'); return false; } return true; } public requestEditorState() { this.sendToUnity({ type: 'requestEditorState', data: {} }); } public async requestSceneInfo(detailLevel: string): Promise<any> { return this.makeUnityRequest('getSceneInfo', { detailLevel }, 'sceneInfo'); } public async requestGameObjectsInfo(instanceIDs: number[], detailLevel: string): Promise<any> { return this.makeUnityRequest('getGameObjectsInfo', { instanceIDs, detailLevel }, 'gameObjectDetails'); } private async makeUnityRequest(type: string, data: any, resultField: string): Promise<any> { if (!this.isConnected()) { throw new Error('Unity Editor is not connected'); } const requestId = crypto.randomUUID(); data.requestId = requestId; // Create a promise that will be resolved when we get the response const responsePromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { delete this.pendingRequests[requestId]; reject(new Error(`Request for ${type} timed out`)); }, 10000); // 10 second timeout this.pendingRequests[requestId] = { resolve: (data) => { clearTimeout(timeout); resolve(data[resultField]); }, reject, type }; }); // Send the request to Unity this.sendToUnity({ type, data }); return responsePromise; } // Support for file system tools by adding a method to send generic messages public async sendMessage(message: string | object) { if (this.unityConnection && this.unityConnection.readyState === WebSocket.OPEN) { const messageStr = typeof message === 'string' ? message : JSON.stringify(message); return new Promise<void>((resolve, reject) => { this.unityConnection!.send(messageStr, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } return Promise.resolve(); } public async close() { if (this.unityConnection) { try { this.unityConnection.close(); } catch (error) { console.error('[Unity MCP] Error closing Unity connection:', error); } this.unityConnection = null; } return new Promise<void>((resolve) => { try { this.wsServer.close(() => { console.error('[Unity MCP] WebSocket server closed'); resolve(); }); } catch (error) { console.error('[Unity MCP] Error closing WebSocket server:', error); resolve(); // Resolve anyway to allow the process to exit } }); } } ``` -------------------------------------------------------------------------------- /Editor/MCPMessageHandler.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using UnityEditor; using UnityEngine; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Linq; namespace Plugins.GamePilot.Editor.MCP { public class MCPMessageHandler { private readonly MCPDataCollector dataCollector; private readonly MCPCodeExecutor codeExecutor; private readonly MCPMessageSender messageSender; public MCPMessageHandler(MCPDataCollector dataCollector, MCPMessageSender messageSender) { this.dataCollector = dataCollector ?? throw new ArgumentNullException(nameof(dataCollector)); this.messageSender = messageSender ?? throw new ArgumentNullException(nameof(messageSender)); this.codeExecutor = new MCPCodeExecutor(); } public async void HandleMessage(string messageJson) { if (string.IsNullOrEmpty(messageJson)) return; try { Debug.Log($"[MCP] Received message: {messageJson}"); var message = JsonConvert.DeserializeObject<MCPMessage>(messageJson); if (message == null) return; switch (message.Type) { case "selectGameObject": await HandleSelectGameObjectAsync(message.Data); break; case "togglePlayMode": await HandleTogglePlayModeAsync(); break; case "executeEditorCommand": await HandleExecuteCommandAsync(message.Data); break; case "requestEditorState": // Consolidated to a single message type await HandleRequestEditorStateAsync(message.Data); break; case "getLogs": await HandleGetLogsAsync(message.Data); break; case "handshake": await HandleHandshakeAsync(message.Data); break; case "getSceneInfo": await HandleGetSceneInfoAsync(message.Data); break; case "getGameObjectsInfo": await HandleGetGameObjectsInfoAsync(message.Data); break; case "ping": // Renamed from 'heartbeat' to 'ping' to match protocol await HandlePingAsync(message.Data); break; default: Debug.LogWarning($"[MCP] Unknown message type: {message.Type}"); break; } } catch (Exception ex) { Debug.LogError($"[MCP] Error handling message: {ex.Message}\nMessage: {messageJson}"); } } private async Task HandleHandshakeAsync(JToken data) { try { string message = data["message"]?.ToString() ?? "Server connected"; Debug.Log($"[MCP] Handshake received: {message}"); // Send a simple acknowledgment, but don't send full editor state until requested await messageSender.SendPongAsync(); } catch (Exception ex) { Debug.LogError($"[MCP] Error handling handshake: {ex.Message}"); } } // Add a ping handler to respond to server heartbeats private async Task HandlePingAsync(JToken data) { try { // Simply respond with a pong message await messageSender.SendPongAsync(); } catch (Exception ex) { Debug.LogError($"[MCP] Error handling ping: {ex.Message}"); } } private async Task HandleSelectGameObjectAsync(JToken data) { try { string objectPath = data["path"]?.ToString(); string requestId = data["requestId"]?.ToString(); if (string.IsNullOrEmpty(objectPath)) return; var obj = GameObject.Find(objectPath); if (obj != null) { Selection.activeGameObject = obj; Debug.Log($"[MCP] Selected GameObject: {objectPath}"); // If requestId was provided, send back object details if (!string.IsNullOrEmpty(requestId)) { // Use the new method to send details for a single GameObject await messageSender.SendGameObjectDetailsAsync(requestId, obj); } } else { Debug.LogWarning($"[MCP] GameObject not found: {objectPath}"); if (!string.IsNullOrEmpty(requestId)) { await messageSender.SendErrorMessageAsync("OBJECT_NOT_FOUND", $"GameObject not found: {objectPath}"); } } } catch (Exception ex) { Debug.LogError($"[MCP] Error selecting GameObject: {ex.Message}"); } } private async Task HandleTogglePlayModeAsync() { try { EditorApplication.isPlaying = !EditorApplication.isPlaying; Debug.Log($"[MCP] Toggled play mode to: {EditorApplication.isPlaying}"); // Send updated editor state after toggling play mode var editorState = dataCollector.GetEditorState(); await messageSender.SendEditorStateAsync(editorState); } catch (Exception ex) { Debug.LogError($"[MCP] Error toggling play mode: {ex.Message}"); } } private async Task HandleExecuteCommandAsync(JToken data) { try { // Support both old and new parameter naming string commandId = data["commandId"]?.ToString() ?? data["id"]?.ToString() ?? Guid.NewGuid().ToString(); string code = data["code"]?.ToString(); if (string.IsNullOrEmpty(code)) { Debug.LogWarning("[MCP] Received empty code to execute"); await messageSender.SendErrorMessageAsync("EMPTY_CODE", "Received empty code to execute"); return; } Debug.Log($"[MCP] Executing command: {commandId}\n{code}"); var result = codeExecutor.ExecuteCode(code); // Send back the results await messageSender.SendCommandResultAsync( commandId, result, codeExecutor.GetLogs(), codeExecutor.GetErrors(), codeExecutor.GetWarnings() ); Debug.Log($"[MCP] Command execution completed"); } catch (Exception ex) { Debug.LogError($"[MCP] Error executing command: {ex.Message}"); } } // Renamed from HandleGetEditorStateAsync to HandleRequestEditorStateAsync for clarity private async Task HandleRequestEditorStateAsync(JToken data) { try { // Get current editor state with enhanced project info var editorState = GetEnhancedEditorState(); // Send it to the server await messageSender.SendEditorStateAsync(editorState); } catch (Exception ex) { Debug.LogError($"[MCP] Error getting editor state: {ex.Message}"); } } // New method to get enhanced editor state with more project information private MCPEditorState GetEnhancedEditorState() { // Get base editor state from data collector var state = dataCollector.GetEditorState(); // Add additional project information EnhanceEditorStateWithProjectInfo(state); return state; } // Add additional project information to the editor state private void EnhanceEditorStateWithProjectInfo(MCPEditorState state) { try { // Add information about current rendering pipeline state.RenderPipeline = GetCurrentRenderPipeline(); // Add current build target platform state.BuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString(); // Add project name state.ProjectName = Application.productName; // Add graphics API info state.GraphicsDeviceType = SystemInfo.graphicsDeviceType.ToString(); // Add Unity version state.UnityVersion = Application.unityVersion; // Add current scene name var currentScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); state.CurrentSceneName = currentScene.name; state.CurrentScenePath = currentScene.path; } catch (Exception ex) { Debug.LogError($"[MCP] Error enhancing editor state: {ex.Message}"); } } // Helper to determine current render pipeline private string GetCurrentRenderPipeline() { if (UnityEngine.Rendering.GraphicsSettings.defaultRenderPipeline == null) return "Built-in Render Pipeline"; var pipelineType = UnityEngine.Rendering.GraphicsSettings.defaultRenderPipeline.GetType().Name; // Try to make the name more user-friendly if (pipelineType.Contains("Universal")) return "Universal Render Pipeline (URP)"; else if (pipelineType.Contains("HD")) return "High Definition Render Pipeline (HDRP)"; else if (pipelineType.Contains("Lightweight")) return "Lightweight Render Pipeline (LWRP)"; else return pipelineType; } private async Task HandleGetLogsAsync(JToken data) { try { string requestId = data["requestId"]?.ToString() ?? Guid.NewGuid().ToString(); int count = data["count"]?.Value<int>() ?? 50; // Get logs from collector var logs = dataCollector.GetRecentLogs(count); // Send logs back to server await messageSender.SendGetLogsResponseAsync(requestId, logs); } catch (Exception ex) { Debug.LogError($"[MCP] Error getting logs: {ex.Message}"); } } private async Task HandleGetSceneInfoAsync(JToken data) { try { string requestId = data["requestId"]?.ToString() ?? Guid.NewGuid().ToString(); string detailLevelStr = data["detailLevel"]?.ToString() ?? "RootObjectsOnly"; // Parse the detail level SceneInfoDetail detailLevel; if (!Enum.TryParse(detailLevelStr, true, out detailLevel)) { detailLevel = SceneInfoDetail.RootObjectsOnly; } // Get scene info var sceneInfo = dataCollector.GetCurrentSceneInfo(detailLevel); // Send it to the server await messageSender.SendSceneInfoAsync(requestId, sceneInfo); } catch (Exception ex) { Debug.LogError($"[MCP] Error handling getSceneInfo: {ex.Message}"); await messageSender.SendErrorMessageAsync("SCENE_INFO_ERROR", ex.Message); } } private async Task HandleGetGameObjectsInfoAsync(JToken data) { try { string requestId = data["requestId"]?.ToString() ?? Guid.NewGuid().ToString(); string detailLevelStr = data["detailLevel"]?.ToString() ?? "BasicInfo"; // Get the list of instance IDs int[] instanceIDs; if (data["instanceIDs"] != null && data["instanceIDs"].Type == JTokenType.Array) { instanceIDs = data["instanceIDs"].ToObject<int[]>(); } else { await messageSender.SendErrorMessageAsync("INVALID_PARAMS", "instanceIDs array is required"); return; } // Parse the detail level GameObjectInfoDetail detailLevel; if (!Enum.TryParse(detailLevelStr, true, out detailLevel)) { detailLevel = GameObjectInfoDetail.BasicInfo; } // Get game object details var gameObjectDetails = dataCollector.GetGameObjectsInfo(instanceIDs, detailLevel); // Send to server await messageSender.SendGameObjectsDetailsAsync(requestId, gameObjectDetails); } catch (Exception ex) { Debug.LogError($"[MCP] Error handling getGameObjectsInfo: {ex.Message}"); await messageSender.SendErrorMessageAsync("GAME_OBJECT_INFO_ERROR", ex.Message); } } } internal class MCPMessage { [JsonProperty("type")] public string Type { get; set; } [JsonProperty("data")] public JToken Data { get; set; } } } ``` -------------------------------------------------------------------------------- /Editor/MCPDataCollector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; namespace Plugins.GamePilot.Editor.MCP { public enum SceneInfoDetail { RootObjectsOnly, FullHierarchy } public enum GameObjectInfoDetail { BasicInfo, IncludeComponents, IncludeChildren, IncludeComponentsAndChildren // New option to include both components and children } public class MCPDataCollector : IDisposable { private readonly Queue<LogEntry> logBuffer = new Queue<LogEntry>(); private readonly int maxLogBufferSize = 1000; private bool isLoggingEnabled = true; public MCPDataCollector() { // Start capturing logs Application.logMessageReceived += HandleLogMessage; } public void Dispose() { // Unsubscribe to prevent memory leaks Application.logMessageReceived -= HandleLogMessage; } private void HandleLogMessage(string message, string stackTrace, LogType type) { if (!isLoggingEnabled) return; var logEntry = new LogEntry { Message = message, StackTrace = stackTrace, Type = type, Timestamp = DateTime.UtcNow }; lock (logBuffer) { logBuffer.Enqueue(logEntry); while (logBuffer.Count > maxLogBufferSize) { logBuffer.Dequeue(); } } } public bool IsLoggingEnabled { get => isLoggingEnabled; set { if (isLoggingEnabled == value) return; isLoggingEnabled = value; if (value) { Application.logMessageReceived += HandleLogMessage; } else { Application.logMessageReceived -= HandleLogMessage; } } } public LogEntry[] GetRecentLogs(int count = 50) { lock (logBuffer) { return logBuffer.Reverse().Take(count).Reverse().ToArray(); } } public MCPEditorState GetEditorState() { var state = new MCPEditorState { ActiveGameObjects = GetActiveGameObjects(), SelectedObjects = GetSelectedObjects(), PlayModeState = EditorApplication.isPlaying ? "Playing" : "Stopped", SceneHierarchy = GetSceneHierarchy(), Timestamp = DateTime.UtcNow }; return state; } private string[] GetActiveGameObjects() { try { var foundObjects = GameObject.FindObjectsByType<GameObject>(FindObjectsSortMode.None); return foundObjects.Where(o => o != null).Select(obj => obj.name).ToArray(); } catch (Exception ex) { Debug.LogError($"[MCP] Error getting active GameObjects: {ex.Message}"); return new string[0]; } } private string[] GetSelectedObjects() { try { return Selection.gameObjects.Where(o => o != null).Select(obj => obj.name).ToArray(); } catch (Exception ex) { Debug.LogError($"[MCP] Error getting selected objects: {ex.Message}"); return new string[0]; } } private List<MCPGameObjectInfo> GetSceneHierarchy() { var hierarchy = new List<MCPGameObjectInfo>(); try { var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); if (scene.IsValid()) { var rootObjects = scene.GetRootGameObjects(); foreach (var root in rootObjects.Where(o => o != null)) { hierarchy.Add(GetGameObjectHierarchy(root)); } } } catch (Exception ex) { Debug.LogError($"[MCP] Error getting scene hierarchy: {ex.Message}"); } return hierarchy; } private MCPGameObjectInfo GetGameObjectHierarchy(GameObject obj) { if (obj == null) return null; try { var info = new MCPGameObjectInfo { Name = obj.name, Path = GetGameObjectPath(obj), Components = obj.GetComponents<Component>() .Where(c => c != null) .Select(c => c.GetType().Name) .ToArray(), Children = new List<MCPGameObjectInfo>(), Active = obj.activeSelf, Layer = obj.layer, Tag = obj.tag }; var transform = obj.transform; for (int i = 0; i < transform.childCount; i++) { var childTransform = transform.GetChild(i); if (childTransform != null && childTransform.gameObject != null) { var childInfo = GetGameObjectHierarchy(childTransform.gameObject); if (childInfo != null) { info.Children.Add(childInfo); } } } return info; } catch (Exception ex) { Debug.LogWarning($"[MCP] Error processing GameObject {obj.name}: {ex.Message}"); return new MCPGameObjectInfo { Name = obj.name, Path = GetGameObjectPath(obj) }; } } private string GetGameObjectPath(GameObject obj) { if (obj == null) return string.Empty; try { string path = obj.name; Transform parent = obj.transform.parent; while (parent != null) { path = parent.name + "/" + path; parent = parent.parent; } return path; } catch (Exception) { return obj.name; } } // New method to get current scene information based on requested detail level public MCPSceneInfo GetCurrentSceneInfo(SceneInfoDetail detailLevel) { try { var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); var sceneInfo = new MCPSceneInfo { Name = scene.name, Path = scene.path, IsDirty = scene.isDirty, RootCount = scene.rootCount }; if (scene.IsValid()) { var rootObjects = scene.GetRootGameObjects(); // Map scene objects based on detail level requested switch (detailLevel) { case SceneInfoDetail.RootObjectsOnly: sceneInfo.RootObjects = rootObjects .Where(o => o != null) .Select(o => new MCPGameObjectReference { Name = o.name, InstanceID = o.GetInstanceID(), Path = GetGameObjectPath(o), Active = o.activeSelf, ChildCount = o.transform.childCount }) .ToList(); break; case SceneInfoDetail.FullHierarchy: sceneInfo.RootObjects = rootObjects .Where(o => o != null) .Select(o => GetGameObjectReferenceWithChildren(o)) .ToList(); break; } } return sceneInfo; } catch (Exception ex) { Debug.LogError($"[MCP] Error getting scene info: {ex.Message}"); return new MCPSceneInfo { Name = "Error", ErrorMessage = ex.Message }; } } // Helper method to create a game object reference with children private MCPGameObjectReference GetGameObjectReferenceWithChildren(GameObject obj) { if (obj == null) return null; var reference = new MCPGameObjectReference { Name = obj.name, InstanceID = obj.GetInstanceID(), Path = GetGameObjectPath(obj), Active = obj.activeSelf, ChildCount = obj.transform.childCount, Children = new List<MCPGameObjectReference>() }; // Add all children var transform = obj.transform; for (int i = 0; i < transform.childCount; i++) { var childTransform = transform.GetChild(i); if (childTransform != null && childTransform.gameObject != null) { var childRef = GetGameObjectReferenceWithChildren(childTransform.gameObject); if (childRef != null) { reference.Children.Add(childRef); } } } return reference; } // Get detailed information about specific game objects public List<MCPGameObjectDetail> GetGameObjectsInfo(int[] objectInstanceIDs, GameObjectInfoDetail detailLevel) { var results = new List<MCPGameObjectDetail>(); try { foreach (var id in objectInstanceIDs) { var obj = EditorUtility.InstanceIDToObject(id) as GameObject; if (obj != null) { results.Add(GetGameObjectDetail(obj, detailLevel)); } } } catch (Exception ex) { Debug.LogError($"[MCP] Error getting game object details: {ex.Message}"); } return results; } // Helper method to get detailed info about a game object private MCPGameObjectDetail GetGameObjectDetail(GameObject obj, GameObjectInfoDetail detailLevel) { var detail = new MCPGameObjectDetail { Name = obj.name, InstanceID = obj.GetInstanceID(), Path = GetGameObjectPath(obj), Active = obj.activeSelf, ActiveInHierarchy = obj.activeInHierarchy, Tag = obj.tag, Layer = obj.layer, LayerName = LayerMask.LayerToName(obj.layer), IsStatic = obj.isStatic, Transform = new MCPTransformInfo { Position = obj.transform.position, Rotation = obj.transform.rotation.eulerAngles, LocalPosition = obj.transform.localPosition, LocalRotation = obj.transform.localRotation.eulerAngles, LocalScale = obj.transform.localScale } }; // Include components if requested if (detailLevel == GameObjectInfoDetail.IncludeComponents || detailLevel == GameObjectInfoDetail.IncludeComponentsAndChildren) { detail.Components = obj.GetComponents<Component>() .Where(c => c != null) .Select(c => new MCPComponentInfo { Type = c.GetType().Name, IsEnabled = GetComponentEnabled(c), InstanceID = c.GetInstanceID() }) .ToList(); } // Include children if requested if (detailLevel == GameObjectInfoDetail.IncludeChildren || detailLevel == GameObjectInfoDetail.IncludeComponentsAndChildren) { detail.Children = new List<MCPGameObjectDetail>(); var transform = obj.transform; for (int i = 0; i < transform.childCount; i++) { var childTransform = transform.GetChild(i); if (childTransform != null && childTransform.gameObject != null) { // When including both components and children, make sure to include components for children too GameObjectInfoDetail childDetailLevel = detailLevel == GameObjectInfoDetail.IncludeComponentsAndChildren ? GameObjectInfoDetail.IncludeComponentsAndChildren : GameObjectInfoDetail.BasicInfo; var childDetail = GetGameObjectDetail( childTransform.gameObject, childDetailLevel); detail.Children.Add(childDetail); } } } return detail; } // Helper for getting component enabled state private bool GetComponentEnabled(Component component) { // Try to check if component is enabled (for components that support it) try { if (component is Behaviour behaviour) return behaviour.enabled; if (component is Renderer renderer) return renderer.enabled; if (component is Collider collider) return collider.enabled; } catch { // Ignore any exceptions } // Default to true for components that don't have an enabled property return true; } } } ``` -------------------------------------------------------------------------------- /mcpServer/src/filesystemTools.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { WebSocketHandler } from './websocketHandler.js'; import fs from 'fs/promises'; import { Dirent } from 'fs'; // Import Dirent instead of DirEnt import path from 'path'; import { createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; import { ReadFileArgsSchema, ReadMultipleFilesArgsSchema, WriteFileArgsSchema, EditFileArgsSchema, ListDirectoryArgsSchema, DirectoryTreeArgsSchema, SearchFilesArgsSchema, GetFileInfoArgsSchema, FindAssetsByTypeArgsSchema } from './toolDefinitions.js'; // Interface definitions interface FileInfo { size: number; created: Date; modified: Date; accessed: Date; isDirectory: boolean; isFile: boolean; permissions: string; } interface TreeEntry { name: string; type: 'file' | 'directory'; children?: TreeEntry[]; } // Helper functions async function validatePath(requestedPath: string, assetRootPath: string): Promise<string> { // If path is empty or just quotes, use the asset root path directly if (!requestedPath || requestedPath.trim() === '' || requestedPath.trim() === '""' || requestedPath.trim() === "''") { console.error(`[Unity MCP] Using asset root path: ${assetRootPath}`); return assetRootPath; } // Clean the path to remove any unexpected quotes or escape characters let cleanPath = requestedPath.replace(/['"\\]/g, ''); // Handle empty path after cleaning if (!cleanPath || cleanPath.trim() === '') { return assetRootPath; } // Normalize path to handle both Windows and Unix-style paths const normalized = path.normalize(cleanPath); // Resolve the path (absolute or relative) let absolute = resolvePathToAssetRoot(normalized, assetRootPath); const resolvedPath = path.resolve(absolute); // Ensure we don't escape out of the Unity project folder validatePathSecurity(resolvedPath, assetRootPath, requestedPath); return resolvedPath; } function resolvePathToAssetRoot(pathToResolve: string, assetRootPath: string): string { if (path.isAbsolute(pathToResolve)) { console.error(`[Unity MCP] Absolute path requested: ${pathToResolve}`); // If the absolute path is outside the project, try alternative resolutions if (!pathToResolve.startsWith(assetRootPath)) { // Try 1: Treat as relative path const tryRelative = path.join(assetRootPath, pathToResolve); try { fs.access(tryRelative); console.error(`[Unity MCP] Treating as relative path: ${tryRelative}`); return tryRelative; } catch { // Try 2: Try to extract path relative to Assets if it contains "Assets" if (pathToResolve.includes('Assets')) { const assetsIndex = pathToResolve.indexOf('Assets'); const relativePath = pathToResolve.substring(assetsIndex + 7); // +7 to skip "Assets/" const newPath = path.join(assetRootPath, relativePath); console.error(`[Unity MCP] Trying via Assets path: ${newPath}`); try { fs.access(newPath); return newPath; } catch { /* Use original if all else fails */ } } } } return pathToResolve; } else { // For relative paths, join with asset root path return path.join(assetRootPath, pathToResolve); } } function validatePathSecurity(resolvedPath: string, assetRootPath: string, requestedPath: string): void { if (!resolvedPath.startsWith(assetRootPath) && requestedPath.trim() !== '') { console.error(`[Unity MCP] Access denied: Path ${requestedPath} is outside the project directory`); console.error(`[Unity MCP] Resolved to: ${resolvedPath}`); console.error(`[Unity MCP] Expected to be within: ${assetRootPath}`); throw new Error(`Access denied: Path ${requestedPath} is outside the Unity project directory`); } } async function getFileStats(filePath: string): Promise<FileInfo> { const stats = await fs.stat(filePath); return { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), }; } async function searchFiles( rootPath: string, pattern: string, excludePatterns: string[] = [] ): Promise<string[]> { const results: string[] = []; async function search(currentPath: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); try { // Check if path matches any exclude pattern const relativePath = path.relative(rootPath, fullPath); if (isPathExcluded(relativePath, excludePatterns)) continue; if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { results.push(fullPath); } if (entry.isDirectory()) { await search(fullPath); } } catch (error) { // Skip invalid paths during search continue; } } } await search(rootPath); return results; } function isPathExcluded(relativePath: string, excludePatterns: string[]): boolean { return excludePatterns.some(pattern => { const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`; return minimatch(relativePath, globPattern, { dot: true }); }); } function normalizeLineEndings(text: string): string { return text.replace(/\r\n/g, '\n'); } function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { // Ensure consistent line endings for diff const normalizedOriginal = normalizeLineEndings(originalContent); const normalizedNew = normalizeLineEndings(newContent); return createTwoFilesPatch( filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified' ); } async function applyFileEdits( filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun = false ): Promise<string> { // Read file content and normalize line endings const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8')); // Apply edits sequentially let modifiedContent = content; for (const edit of edits) { const normalizedOld = normalizeLineEndings(edit.oldText); const normalizedNew = normalizeLineEndings(edit.newText); // If exact match exists, use it if (modifiedContent.includes(normalizedOld)) { modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); continue; } // Try line-by-line matching with whitespace flexibility modifiedContent = applyFlexibleLineEdit(modifiedContent, normalizedOld, normalizedNew); } // Create unified diff const diff = createUnifiedDiff(content, modifiedContent, filePath); const formattedDiff = formatDiff(diff); if (!dryRun) { await fs.writeFile(filePath, modifiedContent, 'utf-8'); } return formattedDiff; } function applyFlexibleLineEdit(content: string, oldText: string, newText: string): string { const oldLines = oldText.split('\n'); const contentLines = content.split('\n'); for (let i = 0; i <= contentLines.length - oldLines.length; i++) { const potentialMatch = contentLines.slice(i, i + oldLines.length); // Compare lines with normalized whitespace const isMatch = oldLines.every((oldLine, j) => { const contentLine = potentialMatch[j]; return oldLine.trim() === contentLine.trim(); }); if (isMatch) { // Preserve indentation const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''; const newLines = newText.split('\n').map((line, j) => { if (j === 0) return originalIndent + line.trimStart(); const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''; const newIndent = line.match(/^\s*/)?.[0] || ''; if (oldIndent && newIndent) { const relativeIndent = newIndent.length - oldIndent.length; return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart(); } return line; }); contentLines.splice(i, oldLines.length, ...newLines); return contentLines.join('\n'); } } throw new Error(`Could not find exact match for edit:\n${oldText}`); } function formatDiff(diff: string): string { let numBackticks = 3; while (diff.includes('`'.repeat(numBackticks))) { numBackticks++; } return `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`; } async function buildDirectoryTree(currentPath: string, assetRootPath: string, maxDepth: number = 5, currentDepth: number = 0): Promise<TreeEntry[]> { if (currentDepth >= maxDepth) { return [{ name: "...", type: "directory" }]; } const validPath = await validatePath(currentPath, assetRootPath); const entries = await fs.readdir(validPath, { withFileTypes: true }); const result: TreeEntry[] = []; for (const entry of entries) { const entryData: TreeEntry = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' }; if (entry.isDirectory()) { const subPath = path.join(currentPath, entry.name); entryData.children = await buildDirectoryTree(subPath, assetRootPath, maxDepth, currentDepth + 1); } result.push(entryData); } return result; } // Function to recognize Unity asset types based on file extension function getUnityAssetType(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); // Common Unity asset types const assetTypes: Record<string, string> = { '.unity': 'Scene', '.prefab': 'Prefab', '.mat': 'Material', '.fbx': 'Model', '.cs': 'Script', '.anim': 'Animation', '.controller': 'Animator Controller', '.asset': 'ScriptableObject', '.png': 'Texture', '.jpg': 'Texture', '.jpeg': 'Texture', '.tga': 'Texture', '.wav': 'Audio', '.mp3': 'Audio', '.ogg': 'Audio', '.shader': 'Shader', '.compute': 'Compute Shader', '.ttf': 'Font', '.otf': 'Font', '.physicMaterial': 'Physics Material', '.mask': 'Avatar Mask', '.playable': 'Playable', '.mixer': 'Audio Mixer', '.renderTexture': 'Render Texture', '.lighting': 'Lighting Settings', '.shadervariants': 'Shader Variants', '.spriteatlas': 'Sprite Atlas', '.guiskin': 'GUI Skin', '.flare': 'Flare', '.brush': 'Brush', '.overrideController': 'Animator Override Controller', '.preset': 'Preset', '.terrainlayer': 'Terrain Layer', '.signal': 'Signal', '.signalasset': 'Signal Asset', '.giparams': 'Global Illumination Parameters', '.cubemap': 'Cubemap', }; return assetTypes[ext] || 'Other'; } // Get file extensions for Unity asset types function getFileExtensionsForType(type: string): string[] { type = type.toLowerCase(); const extensionMap: Record<string, string[]> = { 'scene': ['.unity'], 'prefab': ['.prefab'], 'material': ['.mat'], 'script': ['.cs'], 'model': ['.fbx', '.obj', '.blend', '.max', '.mb', '.ma'], 'texture': ['.png', '.jpg', '.jpeg', '.tga', '.tif', '.tiff', '.psd', '.exr', '.hdr'], 'audio': ['.wav', '.mp3', '.ogg', '.aiff', '.aif'], 'animation': ['.anim'], 'animator': ['.controller'], 'shader': ['.shader', '.compute', '.cginc'] }; return extensionMap[type] || []; } // Handler function to process filesystem tools export async function handleFilesystemTool(name: string, args: any, projectPath: string) { try { switch (name) { case "read_file": { const parsed = ReadFileArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const validPath = await validatePath(parsed.data.path, projectPath); const content = await fs.readFile(validPath, "utf-8"); return { content: [{ type: "text", text: content }] }; } case "read_multiple_files": { const parsed = ReadMultipleFilesArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const results = await Promise.all( parsed.data.paths.map(async (filePath: string) => { try { const validPath = await validatePath(filePath, projectPath); const content = await fs.readFile(validPath, "utf-8"); return `${filePath}:\n${content}\n`; } catch (error) { return `${filePath}: Error - ${getErrorMessage(error)}`; } }), ); return { content: [{ type: "text", text: results.join("\n---\n") }] }; } case "write_file": { const parsed = WriteFileArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const validPath = await validatePath(parsed.data.path, projectPath); // Ensure directory exists const dirPath = path.dirname(validPath); await fs.mkdir(dirPath, { recursive: true }); await fs.writeFile(validPath, parsed.data.content, "utf-8"); return { content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }] }; } case "edit_file": { const parsed = EditFileArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const validPath = await validatePath(parsed.data.path, projectPath); const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); return { content: [{ type: "text", text: result }] }; } case "list_directory": { const parsed = ListDirectoryArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const validPath = await validatePath(parsed.data.path, projectPath); const entries = await fs.readdir(validPath, { withFileTypes: true }); const formatted = entries .map((entry) => formatDirectoryEntry(entry, validPath)) .join("\n"); return { content: [{ type: "text", text: formatted }] }; } case "directory_tree": { const parsed = DirectoryTreeArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const treeData = await buildDirectoryTree(parsed.data.path, projectPath, parsed.data.maxDepth); return { content: [{ type: "text", text: JSON.stringify(treeData, null, 2) }] }; } case "search_files": { const parsed = SearchFilesArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const validPath = await validatePath(parsed.data.path, projectPath); const results = await searchFiles(validPath, parsed.data.pattern, parsed.data.excludePatterns); return { content: [{ type: "text", text: results.length > 0 ? `Found ${results.length} results:\n${results.join("\n")}` : "No matches found" }] }; } case "get_file_info": { const parsed = GetFileInfoArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const validPath = await validatePath(parsed.data.path, projectPath); const info = await getFileStats(validPath); // Add Unity-specific info if it's an asset file const additionalInfo: Record<string, string> = {}; if (info.isFile) { additionalInfo.assetType = getUnityAssetType(validPath); } const formattedInfo = Object.entries({ ...info, ...additionalInfo }) .map(([key, value]) => `${key}: ${value}`) .join("\n"); return { content: [{ type: "text", text: formattedInfo }] }; } case "find_assets_by_type": { const parsed = FindAssetsByTypeArgsSchema.safeParse(args); if (!parsed.success) return invalidArgsResponse(parsed.error); const assetType = parsed.data.assetType.replace(/['"]/g, ''); const searchPath = parsed.data.searchPath.replace(/['"]/g, ''); const maxDepth = parsed.data.maxDepth; console.error(`[Unity MCP] Finding assets of type "${assetType}" in path "${searchPath}" with maxDepth ${maxDepth}`); const validPath = await validatePath(searchPath, projectPath); const results = await findAssetsByType(assetType, validPath, maxDepth, projectPath); return { content: [{ type: "text", text: results.length > 0 ? `Found ${results.length} ${assetType} assets:\n${JSON.stringify(results, null, 2)}` : `No "${assetType}" assets found in ${searchPath || "Assets"}` }] }; } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } } catch (error) { return { content: [{ type: "text", text: `Error: ${getErrorMessage(error)}` }], isError: true, }; } } function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function invalidArgsResponse(error: any) { return { content: [{ type: "text", text: `Invalid arguments: ${error}` }], isError: true }; } // Fixed function to use proper Dirent type function formatDirectoryEntry(entry: Dirent, basePath: string): string { if (entry.isDirectory()) { return `[DIR] ${entry.name}`; } else { // For files, detect Unity asset type const filePath = path.join(basePath, entry.name); const assetType = getUnityAssetType(filePath); return `[${assetType}] ${entry.name}`; } } async function findAssetsByType( assetType: string, searchPath: string, maxDepth: number, projectPath: string ): Promise<Array<{path: string, name: string, type: string}>> { const results: Array<{path: string, name: string, type: string}> = []; const extensions = getFileExtensionsForType(assetType); async function searchAssets(dir: string, currentDepth: number = 1) { // Stop recursion if we've reached the maximum depth if (maxDepth !== -1 && currentDepth > maxDepth) { return; } try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(projectPath, fullPath); if (entry.isDirectory()) { // Recursively search subdirectories await searchAssets(fullPath, currentDepth + 1); } else { // Check if the file matches the requested asset type const ext = path.extname(entry.name).toLowerCase(); if (extensions.length === 0) { // If no extensions specified, match by Unity asset type const fileAssetType = getUnityAssetType(fullPath); if (fileAssetType.toLowerCase() === assetType.toLowerCase()) { results.push({ path: relativePath, name: entry.name, type: fileAssetType }); } } else if (extensions.includes(ext)) { // Match by extension results.push({ path: relativePath, name: entry.name, type: assetType }); } } } } catch (error) { console.error(`Error accessing directory ${dir}:`, error); } } await searchAssets(searchPath); return results; } // This function is deprecated and now just a stub export function registerFilesystemTools(server: Server, wsHandler: WebSocketHandler) { console.log("Filesystem tools are now registered in toolDefinitions.ts"); } ``` -------------------------------------------------------------------------------- /mcpServer/src/toolDefinitions.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { WebSocketHandler } from './websocketHandler.js'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import path from 'path'; // Import handleFilesystemTool using ES module syntax instead of require import { handleFilesystemTool } from './filesystemTools.js'; // File operation schemas - defined here to be used in tool definitions export const ReadFileArgsSchema = z.object({ path: z.string().describe('Path to the file to read. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder.'), }); export const ReadMultipleFilesArgsSchema = z.object({ paths: z.array(z.string()).describe('Array of file paths to read. Paths can be absolute or relative to Unity project Assets folder.'), }); export const WriteFileArgsSchema = z.object({ path: z.string().describe('Path to the file to write. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder.'), content: z.string().describe('Content to write to the file'), }); export const EditOperation = z.object({ oldText: z.string().describe('Text to search for - must match exactly'), newText: z.string().describe('Text to replace with') }); export const EditFileArgsSchema = z.object({ path: z.string().describe('Path to the file to edit. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder.'), edits: z.array(EditOperation).describe('Array of edit operations to apply'), dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); export const ListDirectoryArgsSchema = z.object({ path: z.string().describe('Path to the directory to list. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder. Example: "Scenes" will list all files in the Assets/Scenes directory.'), }); export const DirectoryTreeArgsSchema = z.object({ path: z.string().describe('Path to the directory to get tree of. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder. Example: "Prefabs" will show the tree for Assets/Prefabs.'), maxDepth: z.number().optional().default(5).describe('Maximum depth to traverse'), }); export const SearchFilesArgsSchema = z.object({ path: z.string().describe('Path to search from. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder. Example: "Scripts" will search within Assets/Scripts.'), pattern: z.string().describe('Pattern to search for'), excludePatterns: z.array(z.string()).optional().default([]).describe('Patterns to exclude') }); export const GetFileInfoArgsSchema = z.object({ path: z.string().describe('Path to the file to get info for. Can be absolute or relative to Unity project Assets folder. If empty, defaults to the Assets folder.'), }); export const FindAssetsByTypeArgsSchema = z.object({ assetType: z.string().describe('Type of assets to find (e.g., "Material", "Prefab", "Scene", "Script")'), searchPath: z.string().optional().default("").describe('Directory to search in. Can be absolute or relative to Unity project Assets folder. An empty string will search the entire Assets folder.'), maxDepth: z.number().optional().default(1).describe('Maximum depth to search. 1 means search only in the specified directory, 2 includes immediate subdirectories, and so on. Set to -1 for unlimited depth.'), }); export function registerTools(server: Server, wsHandler: WebSocketHandler) { // Determine project path from environment variable (which now should include 'Assets') const projectPath = process.env.UNITY_PROJECT_PATH || path.resolve(process.cwd()); const projectRootPath = projectPath.endsWith(`Assets${path.sep}`) ? projectPath.slice(0, -7) // Remove 'Assets/' : projectPath; console.error(`[Unity MCP ToolDefinitions] Using project path: ${projectPath}`); console.error(`[Unity MCP ToolDefinitions] Using project root path: ${projectRootPath}`); // List all available tools (both Unity and filesystem tools) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ // Unity Editor tools { name: 'get_current_scene_info', description: 'Retrieve information about the current scene in Unity Editor with configurable detail level', category: 'Editor State', tags: ['unity', 'editor', 'scene'], inputSchema: { type: 'object', properties: { detailLevel: { type: 'string', enum: ['RootObjectsOnly', 'FullHierarchy'], description: 'RootObjectsOnly: Returns just root GameObjects. FullHierarchy: Returns complete hierarchy with all children.', default: 'RootObjectsOnly' } }, additionalProperties: false }, returns: { type: 'object', description: 'Returns information about the current scene and its hierarchy based on requested detail level' } }, { name: 'get_game_objects_info', description: 'Retrieve detailed information about specific GameObjects in the current scene', category: 'Editor State', tags: ['unity', 'editor', 'gameobjects'], inputSchema: { type: 'object', properties: { instanceIDs: { type: 'array', items: { type: 'number' }, description: 'Array of GameObject instance IDs to get information for', minItems: 1 }, detailLevel: { type: 'string', enum: ['BasicInfo', 'IncludeComponents', 'IncludeChildren', 'IncludeComponentsAndChildren'], description: 'BasicInfo: Basic GameObject information. IncludeComponents: Includes component details. IncludeChildren: Includes child GameObjects. IncludeComponentsAndChildren: Includes both components and a full hierarchy with components on children.', default: 'IncludeComponents' } }, required: ['instanceIDs'], additionalProperties: false }, returns: { type: 'object', description: 'Returns detailed information about the requested GameObjects' } }, { name: 'execute_editor_command', description: 'Execute C# code directly in the Unity Editor - allows full flexibility including custom namespaces and multiple classes', category: 'Editor Control', tags: ['unity', 'editor', 'command', 'c#'], inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'C# code to execute in Unity Editor. You MUST define a public class named "McpScript" with a public static method named "Execute" that returns an object. Example: "public class McpScript { public static object Execute() { /* your code here */ return result; } }". You can include any necessary namespaces, additional classes, and methods.', minLength: 1 } }, required: ['code'], additionalProperties: false }, returns: { type: 'object', description: 'Returns the execution result, execution time, and status' } }, { name: 'get_logs', description: 'Retrieve Unity Editor logs with filtering options', category: 'Debugging', tags: ['unity', 'editor', 'logs', 'debugging'], inputSchema: { type: 'object', properties: { types: { type: 'array', items: { type: 'string', enum: ['Log', 'Warning', 'Error', 'Exception'] }, description: 'Filter logs by type' }, count: { type: 'number', description: 'Maximum number of log entries to return', minimum: 1, maximum: 1000 }, fields: { type: 'array', items: { type: 'string', enum: ['message', 'stackTrace', 'logType', 'timestamp'] }, description: 'Specify which fields to include in the output' }, messageContains: { type: 'string', description: 'Filter logs by message content' }, stackTraceContains: { type: 'string', description: 'Filter logs by stack trace content' }, timestampAfter: { type: 'string', description: 'Filter logs after this ISO timestamp' }, timestampBefore: { type: 'string', description: 'Filter logs before this ISO timestamp' } }, additionalProperties: false }, returns: { type: 'array', description: 'Returns an array of log entries matching the specified filters' } }, { name: 'verify_connection', description: 'Verify that the MCP server has an active connection to Unity Editor', category: 'Connection', tags: ['unity', 'editor', 'connection'], inputSchema: { type: 'object', properties: {}, additionalProperties: false }, returns: { type: 'object', description: 'Returns connection status information' } }, { name: 'get_editor_state', description: 'Get the current Unity Editor state including project information', category: 'Editor State', tags: ['unity', 'editor', 'project'], inputSchema: { type: 'object', properties: {}, additionalProperties: false }, returns: { type: 'object', description: 'Returns detailed information about the current Unity Editor state, project settings, and environment' } }, // Filesystem tools - defined alongside Unity tools { name: "read_file", description: "Read the contents of a file from the Unity project. Paths are relative to the project's Assets folder. For example, use 'Scenes/MainScene.unity' to read Assets/Scenes/MainScene.unity.", category: "Filesystem", tags: ['unity', 'filesystem', 'file'], inputSchema: zodToJsonSchema(ReadFileArgsSchema), }, { name: "read_multiple_files", description: "Read the contents of multiple files from the Unity project simultaneously.", category: "Filesystem", tags: ['unity', 'filesystem', 'file', 'batch'], inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema), }, { name: "write_file", description: "Create a new file or completely overwrite an existing file in the Unity project.", category: "Filesystem", tags: ['unity', 'filesystem', 'file', 'write'], inputSchema: zodToJsonSchema(WriteFileArgsSchema), }, { name: "edit_file", description: "Make precise edits to a text file in the Unity project. Returns a git-style diff showing changes.", category: "Filesystem", tags: ['unity', 'filesystem', 'file', 'edit'], inputSchema: zodToJsonSchema(EditFileArgsSchema), }, { name: "list_directory", description: "Get a listing of all files and directories in a specified path in the Unity project. Paths are relative to the Assets folder unless absolute. For example, use 'Scenes' to list all files in Assets/Scenes directory. Use empty string to list the Assets folder.", category: "Filesystem", tags: ['unity', 'filesystem', 'directory', 'list'], inputSchema: zodToJsonSchema(ListDirectoryArgsSchema), }, { name: "directory_tree", description: "Get a recursive tree view of files and directories in the Unity project as a JSON structure.", category: "Filesystem", tags: ['unity', 'filesystem', 'directory', 'tree'], inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema), }, { name: "search_files", description: "Recursively search for files and directories matching a pattern in the Unity project.", category: "Filesystem", tags: ['unity', 'filesystem', 'search'], inputSchema: zodToJsonSchema(SearchFilesArgsSchema), }, { name: "get_file_info", description: "Retrieve detailed metadata about a file or directory in the Unity project.", category: "Filesystem", tags: ['unity', 'filesystem', 'file', 'metadata'], inputSchema: zodToJsonSchema(GetFileInfoArgsSchema), }, { name: "find_assets_by_type", description: "Find all Unity assets of a specified type (e.g., Material, Prefab, Scene, Script) in the project. Set searchPath to an empty string to search the entire Assets folder.", category: "Filesystem", tags: ['unity', 'filesystem', 'assets', 'search'], inputSchema: zodToJsonSchema(FindAssetsByTypeArgsSchema), }, ], })); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; // Special case for verify_connection which should work even if not connected if (name === 'verify_connection') { try { const isConnected = wsHandler.isConnected(); // Always request fresh editor state if connected if (isConnected) { wsHandler.requestEditorState(); } return { content: [{ type: 'text', text: JSON.stringify({ connected: isConnected, timestamp: new Date().toISOString(), message: isConnected ? 'Unity Editor is connected' : 'Unity Editor is not connected. Please ensure the Unity Editor is running with the MCP plugin.' }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ connected: false, timestamp: new Date().toISOString(), message: 'Error checking connection status', error: error instanceof Error ? error.message : 'Unknown error' }, null, 2) }] }; } } // Check if this is a filesystem tool const filesystemTools = [ "read_file", "read_multiple_files", "write_file", "edit_file", "list_directory", "directory_tree", "search_files", "get_file_info", "find_assets_by_type" ]; if (filesystemTools.includes(name)) { try { return await handleFilesystemTool(name, args, projectPath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, }; } } // For all other tools (Unity-specific), verify connection first if (!wsHandler.isConnected()) { throw new McpError( ErrorCode.InternalError, 'Unity Editor is not connected. Please first verify the connection using the verify_connection tool, ' + 'and ensure the Unity Editor is running with the MCP plugin and that the WebSocket connection is established.' ); } switch (name) { case 'get_editor_state': { try { // Always request a fresh editor state before returning wsHandler.requestEditorState(); // Wait a moment for the response to arrive await new Promise(resolve => setTimeout(resolve, 1000)); // Return the current editor state const editorState = wsHandler.getEditorState(); return { content: [{ type: 'text', text: JSON.stringify(editorState, null, 2) }] }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to get editor state: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } case 'get_current_scene_info': { try { const detailLevel = (args?.detailLevel as string) || 'RootObjectsOnly'; // Send request to Unity and wait for response const sceneInfo = await wsHandler.requestSceneInfo(detailLevel); return { content: [{ type: 'text', text: JSON.stringify(sceneInfo, null, 2) }] }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to get scene info: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } case 'get_game_objects_info': { try { if (!args?.instanceIDs || !Array.isArray(args.instanceIDs)) { throw new McpError( ErrorCode.InvalidParams, 'instanceIDs array is required' ); } const instanceIDs = args.instanceIDs; const detailLevel = (args?.detailLevel as string) || 'IncludeComponents'; // Send request to Unity and wait for response const gameObjectsInfo = await wsHandler.requestGameObjectsInfo(instanceIDs, detailLevel); return { content: [{ type: 'text', text: JSON.stringify(gameObjectsInfo, null, 2) }] }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to get GameObject info: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } case 'execute_editor_command': { try { if (!args?.code) { throw new McpError( ErrorCode.InvalidParams, 'The code parameter is required' ); } const startTime = Date.now(); const result = await wsHandler.executeEditorCommand(args.code as string); const executionTime = Date.now() - startTime; return { content: [{ type: 'text', text: JSON.stringify({ result, executionTime: `${executionTime}ms`, status: 'success' }, null, 2) }] }; } catch (error) { if (error instanceof Error) { if (error.message.includes('timed out')) { throw new McpError( ErrorCode.InternalError, 'Command execution timed out. This may indicate a long-running operation or an issue with the Unity Editor.' ); } if (error.message.includes('NullReferenceException')) { throw new McpError( ErrorCode.InvalidParams, 'The code attempted to access a null object. Please check that all GameObject references exist.' ); } if (error.message.includes('not connected')) { throw new McpError( ErrorCode.InternalError, 'Unity Editor connection was lost during command execution. Please verify the connection and try again.' ); } } throw new McpError( ErrorCode.InternalError, `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } case 'get_logs': { try { const options = { types: args?.types as string[] | undefined, count: args?.count as number | undefined, fields: args?.fields as string[] | undefined, messageContains: args?.messageContains as string | undefined, stackTraceContains: args?.stackTraceContains as string | undefined, timestampAfter: args?.timestampAfter as string | undefined, timestampBefore: args?.timestampBefore as string | undefined }; const logs = wsHandler.getLogEntries(options); return { content: [{ type: 'text', text: JSON.stringify(logs, null, 2) }] }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to retrieve logs: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } }); } ``` -------------------------------------------------------------------------------- /Editor/UI/MCPDebugWindow.cs: -------------------------------------------------------------------------------- ```csharp using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; namespace Plugins.GamePilot.Editor.MCP { [Serializable] public class MCPDebugSettings { public int port = 5010; public bool autoReconnect = false; public bool globalLoggingEnabled = false; public Dictionary<string, bool> componentLoggingEnabled = new Dictionary<string, bool>(); } public class MCPDebugWindow : EditorWindow { [SerializeField] private VisualTreeAsset uxml; [SerializeField] private StyleSheet uss; [SerializeField] private VisualTreeAsset m_VisualTreeAsset = default; private Label connectionStatusLabel; private Button connectButton; private Button disconnectButton; private Toggle autoReconnectToggle; private TextField serverPortField; // Component logging toggles private Dictionary<string, Toggle> logToggles = new Dictionary<string, Toggle>(); // Connection info labels private Label lastErrorLabel; private Label connectionTimeLabel; // Statistics elements private Label messagesSentLabel; private Label messagesReceivedLabel; private Label reconnectAttemptsLabel; // Statistics counters private int messagesSent = 0; private int messagesReceived = 0; private int reconnectAttempts = 0; private DateTime? connectionStartTime = null; // Settings private MCPDebugSettings settings; private string settingsPath; [MenuItem("Window/MCP Debug")] public static void ShowWindow() { MCPDebugWindow wnd = GetWindow<MCPDebugWindow>(); wnd.titleContent = new GUIContent("MCP Debug"); wnd.minSize = new Vector2(400, 500); } private void OnEnable() { // Get the path to save settings settingsPath = GetSettingsPath(); // Load or create settings LoadSettings(); } private string GetSettingsPath() { // Get the script location var script = MonoScript.FromScriptableObject(this); var scriptPath = AssetDatabase.GetAssetPath(script); var directoryPath = Path.GetDirectoryName(scriptPath); // Create settings path in the same directory return Path.Combine(directoryPath, "MCPDebugSettings.json"); } private void LoadSettings() { settings = new MCPDebugSettings(); try { // Check if settings file exists if (File.Exists(settingsPath)) { string json = File.ReadAllText(settingsPath); settings = JsonConvert.DeserializeObject<MCPDebugSettings>(json); Debug.Log($"[MCP] [MCPDebugWindow] Loaded settings from {settingsPath}"); } else { // Create default settings settings = new MCPDebugSettings(); SaveSettings(); Debug.Log($"[MCP] [MCPDebugWindow] Created default settings at {settingsPath}"); } } catch (Exception ex) { Debug.LogError($"[MCP] [MCPDebugWindow] Error loading settings: {ex.Message}"); settings = new MCPDebugSettings(); } // Apply settings to MCPLogger MCPLogger.GlobalLoggingEnabled = settings.globalLoggingEnabled; // Apply component logging settings foreach (var pair in settings.componentLoggingEnabled) { MCPLogger.SetComponentLoggingEnabled(pair.Key, pair.Value); } } private void SaveSettings() { try { // Save settings using Newtonsoft.Json which supports dictionaries directly string json = JsonConvert.SerializeObject(settings, Formatting.Indented); File.WriteAllText(settingsPath, json); Debug.Log($"[MCP] [MCPDebugWindow] Saved settings to {settingsPath}"); } catch (Exception ex) { Debug.LogError($"[MCP] [MCPDebugWindow] Error saving settings: {ex.Message}"); } } public void CreateGUI() { VisualElement root = rootVisualElement; if (uxml != null) { uxml.CloneTree(root); } else { Debug.LogError("VisualTreeAsset not found. Please check the path."); } if (uss != null) { root.styleSheets.Add(uss); } else { Debug.LogError("StyleSheet not found. Please check the path."); } // Get UI elements connectionStatusLabel = root.Q<Label>("connection-status"); connectButton = root.Q<Button>("connect-button"); disconnectButton = root.Q<Button>("disconnect-button"); autoReconnectToggle = root.Q<Toggle>("auto-reconnect-toggle"); serverPortField = root.Q<TextField>("server-port-field"); lastErrorLabel = root.Q<Label>("last-error-value"); connectionTimeLabel = root.Q<Label>("connection-time-value"); messagesSentLabel = root.Q<Label>("messages-sent-value"); messagesReceivedLabel = root.Q<Label>("messages-received-value"); reconnectAttemptsLabel = root.Q<Label>("reconnect-attempts-value"); // Apply settings to UI serverPortField.value = settings.port.ToString(); autoReconnectToggle.value = settings.autoReconnect; // Setup UI events connectButton.clicked += OnConnectClicked; disconnectButton.clicked += OnDisconnectClicked; autoReconnectToggle.RegisterValueChangedCallback(OnAutoReconnectChanged); serverPortField.RegisterValueChangedCallback(OnPortChanged); // Setup component logging toggles SetupComponentLoggingToggles(root); // Initialize UI with current state UpdateUIFromState(); // Register for updates EditorApplication.update += OnEditorUpdate; } private void OnPortChanged(ChangeEvent<string> evt) { if (int.TryParse(evt.newValue, out int port) && port >= 1 && port <= 65535) { settings.port = port; SaveSettings(); } } private void CreateFallbackUI(VisualElement root) { // Create a simple fallback UI if UXML fails to load root.Add(new Label("MCP Debug Window - UXML not found") { style = { fontSize = 16, marginBottom = 10 } }); // Removed serverUrlField - only using port field as requested serverPortField = new TextField("Port (Default: 5010)") { value = "5010" }; root.Add(serverPortField); var connectButton = new Button(OnConnectClicked) { text = "Connect" }; root.Add(connectButton); var disconnectButton = new Button(OnDisconnectClicked) { text = "Disconnect" }; root.Add(disconnectButton); var autoReconnectToggle = new Toggle("Auto Reconnect"); autoReconnectToggle.RegisterValueChangedCallback(OnAutoReconnectChanged); root.Add(autoReconnectToggle); connectionStatusLabel = new Label("Status: Not Connected"); root.Add(connectionStatusLabel); } private void SetupComponentLoggingToggles(VisualElement root) { var loggingContainer = root.Q<VisualElement>("logging-container"); // Register MCPDebugWindow as a component for logging MCPLogger.InitializeComponent("MCPDebugWindow", settings.componentLoggingEnabled.ContainsKey("MCPDebugWindow") ? settings.componentLoggingEnabled["MCPDebugWindow"] : false); // Global logging toggle var globalToggle = new Toggle("Enable All Logging"); globalToggle.value = settings.globalLoggingEnabled; globalToggle.RegisterValueChangedCallback(evt => { settings.globalLoggingEnabled = evt.newValue; MCPLogger.GlobalLoggingEnabled = evt.newValue; SaveSettings(); // First make sure all components are properly initialized before updating UI EnsureComponentsInitialized(); // Update all component toggles to show they're enabled/disabled foreach (var componentName in MCPLogger.GetRegisteredComponents()) { if (logToggles.TryGetValue(componentName, out var toggle)) { // Don't disable the toggle UI, just update its interactable state toggle.SetEnabled(true); } } }); loggingContainer.Add(globalToggle); // Add a separator var separator = new VisualElement(); separator.style.height = 1; separator.style.marginTop = 5; separator.style.marginBottom = 5; separator.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f); loggingContainer.Add(separator); // Ensure all components are initialized EnsureComponentsInitialized(); // Create toggles for standard components string[] standardComponents = { "MCPManager", "MCPConnectionManager", "MCPDataCollector", "MCPMessageHandler", "MCPCodeExecutor", "MCPMessageSender", "MCPDebugWindow" // Add the debug window itself }; foreach (string componentName in standardComponents) { bool isEnabled = settings.componentLoggingEnabled.ContainsKey(componentName) ? settings.componentLoggingEnabled[componentName] : false; CreateLoggingToggle(loggingContainer, componentName, $"Enable {componentName} logging", isEnabled); } // Add any additional registered components not in our standard list foreach (var componentName in MCPLogger.GetRegisteredComponents()) { if (!logToggles.ContainsKey(componentName)) { bool isEnabled = settings.componentLoggingEnabled.ContainsKey(componentName) ? settings.componentLoggingEnabled[componentName] : false; CreateLoggingToggle(loggingContainer, componentName, $"Enable {componentName} logging", isEnabled); } } } // Make sure all components are initialized in the logger private void EnsureComponentsInitialized() { string[] standardComponents = { "MCPManager", "MCPConnectionManager", "MCPDataCollector", "MCPMessageHandler", "MCPCodeExecutor", "MCPMessageSender", "MCPDebugWindow" }; foreach (string componentName in standardComponents) { MCPLogger.InitializeComponent(componentName, false); } } private void CreateLoggingToggle(VisualElement container, string componentName, string label, bool initialValue) { var toggle = new Toggle(label); toggle.value = initialValue; // Make all toggles interactive, they'll work based on global enabled state toggle.SetEnabled(true); toggle.RegisterValueChangedCallback(evt => OnLoggingToggleChanged(componentName, evt.newValue)); container.Add(toggle); logToggles[componentName] = toggle; } private void OnLoggingToggleChanged(string componentName, bool enabled) { MCPLogger.SetComponentLoggingEnabled(componentName, enabled); settings.componentLoggingEnabled[componentName] = enabled; SaveSettings(); } private void OnConnectClicked() { // Always use localhost for the WebSocket URL string serverUrl = "ws://localhost"; // Get the server port from the text field string portText = serverPortField.value; // If port is empty, default to 5010 if (string.IsNullOrWhiteSpace(portText)) { portText = "5010"; serverPortField.value = portText; } // Validate port format if (!int.TryParse(portText, out int port) || port < 1 || port > 65535) { EditorUtility.DisplayDialog("Invalid Port", "Please enter a valid port number between 1 and 65535.", "OK"); return; } // Save the port setting settings.port = port; SaveSettings(); try { // Create the WebSocket URL with the specified port Uri uri = new Uri($"{serverUrl}:{port}"); // If we have access to the ConnectionManager, try to update its server URI var connectionManager = GetConnectionManager(); if (connectionManager != null) { // Use reflection to set the serverUri field if it exists var serverUriField = typeof(MCPConnectionManager).GetField("serverUri", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); if (serverUriField != null) { serverUriField.SetValue(connectionManager, uri); } } // Initiate manual connection if (MCPManager.IsInitialized) { MCPManager.RetryConnection(); connectionStartTime = DateTime.Now; UpdateUIFromState(); } else { MCPManager.Initialize(); connectionStartTime = DateTime.Now; UpdateUIFromState(); } } catch (UriFormatException) { EditorUtility.DisplayDialog("Invalid URL", "The URL format is invalid.", "OK"); } catch (Exception ex) { EditorUtility.DisplayDialog("Connection Error", $"Error connecting to server: {ex.Message}", "OK"); } } private void OnDisconnectClicked() { if (MCPManager.IsInitialized) { MCPManager.Shutdown(); connectionStartTime = null; UpdateUIFromState(); } } private void OnAutoReconnectChanged(ChangeEvent<bool> evt) { settings.autoReconnect = evt.newValue; SaveSettings(); if (MCPManager.IsInitialized) { MCPManager.EnableAutoReconnect(evt.newValue); } } private void OnEditorUpdate() { // Update connection status and statistics UpdateUIFromState(); } private void UpdateUIFromState() { bool isInitialized = MCPManager.IsInitialized; bool isConnected = MCPManager.IsConnected; // Only log status if logging is enabled if (MCPLogger.IsLoggingEnabled("MCPDebugWindow")) { Debug.Log($"[MCP] [MCPDebugWindow] Status check: IsInitialized={isInitialized}, IsConnected={isConnected}"); } // Update status label if (!isInitialized) { connectionStatusLabel.text = "Not Initialized"; connectionStatusLabel.RemoveFromClassList("status-connected"); connectionStatusLabel.RemoveFromClassList("status-connecting"); connectionStatusLabel.AddToClassList("status-disconnected"); } else if (isConnected) { connectionStatusLabel.text = "Connected"; connectionStatusLabel.RemoveFromClassList("status-disconnected"); connectionStatusLabel.RemoveFromClassList("status-connecting"); connectionStatusLabel.AddToClassList("status-connected"); // If we're in the connected state, make sure connectionStartTime is set // This ensures the timer works properly if (!connectionStartTime.HasValue) { connectionStartTime = DateTime.Now; } } else { connectionStatusLabel.text = "Disconnected"; connectionStatusLabel.RemoveFromClassList("status-connected"); connectionStatusLabel.RemoveFromClassList("status-connecting"); connectionStatusLabel.AddToClassList("status-disconnected"); // Reset connection time when disconnected connectionStartTime = null; } // Update button states connectButton.SetEnabled(!isConnected); disconnectButton.SetEnabled(isInitialized); serverPortField.SetEnabled(!isConnected); // Only allow port changes when disconnected // Update connection time if connected if (connectionStartTime.HasValue && isConnected) { TimeSpan duration = DateTime.Now - connectionStartTime.Value; connectionTimeLabel.text = $"{duration.Hours:00}:{duration.Minutes:00}:{duration.Seconds:00}"; } else { connectionTimeLabel.text = "00:00:00"; } // Update statistics if available if (isInitialized) { // Get connection statistics var connectionManager = GetConnectionManager(); if (connectionManager != null) { messagesSentLabel.text = connectionManager.MessagesSent.ToString(); messagesReceivedLabel.text = connectionManager.MessagesReceived.ToString(); reconnectAttemptsLabel.text = connectionManager.ReconnectAttempts.ToString(); lastErrorLabel.text = !string.IsNullOrEmpty(connectionManager.LastErrorMessage) ? connectionManager.LastErrorMessage : "None"; } else { messagesSentLabel.text = "0"; messagesReceivedLabel.text = "0"; reconnectAttemptsLabel.text = "0"; lastErrorLabel.text = "None"; } } } // Helper to access connection manager through reflection if needed private MCPConnectionManager GetConnectionManager() { if (!MCPManager.IsInitialized) return null; // Try to access the connection manager using reflection try { var managerType = typeof(MCPManager); var field = managerType.GetField("connectionManager", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); if (field != null) { return field.GetValue(null) as MCPConnectionManager; } } catch (Exception ex) { Debug.LogError($"Error accessing connection manager: {ex.Message}"); } return null; } private void OnDisable() { // Unregister from editor updates EditorApplication.update -= OnEditorUpdate; // Save settings one last time when window is closed SaveSettings(); } } } ```