diff --git a/Server/.eslintrc.json b/Server/.eslintrc.json index 142bd47..27c86bb 100644 --- a/Server/.eslintrc.json +++ b/Server/.eslintrc.json @@ -9,8 +9,8 @@ "plugins": ["@typescript-eslint", "prettier"], "extends": [ "eslint:recommended", - "@typescript-eslint/recommended", - "@typescript-eslint/recommended-requiring-type-checking", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", "prettier" ], "rules": { diff --git a/Server/src/index.ts b/Server/src/index.ts index a4bd93d..4c3652f 100644 --- a/Server/src/index.ts +++ b/Server/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { fileURLToPath } from 'node:url'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -164,8 +165,9 @@ class UnityMCPServer { try { await this.unityClient.connect(); logger.info('Connected to Unity Editor successfully'); - } catch (err: any) { - logger.warn('Could not connect to Unity Editor (running in standalone/test mode without Unity):', err?.message || err); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.warn('Could not connect to Unity Editor (running in standalone/test mode without Unity):', message); // Continue to start the MCP transport anyway so tools can be listed/tested via inspector or clients. // Actual tool calls will fail until Unity is available. } @@ -224,7 +226,11 @@ process.on('uncaughtException', (error: Error) => { process.exit(1); }); -// Start the server -if (import.meta.url === `file://${process.argv[1]}`) { +// Start the server when executed directly (not when imported as a module). +const isMainModule = + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === fileURLToPath(process.argv[1]); + +if (isMainModule) { void main(); } \ No newline at end of file diff --git a/Server/src/tools/asset-tools.ts b/Server/src/tools/asset-tools.ts index 4c94a00..a52f49f 100644 --- a/Server/src/tools/asset-tools.ts +++ b/Server/src/tools/asset-tools.ts @@ -5,58 +5,70 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { return [ { name: 'assets.import', - description: 'Import assets into the Unity project from file paths or URLs.', + description: + 'Import assets into the Unity project from file paths or URLs.', inputSchema: { type: 'object', properties: { sourcePath: { type: 'string', - description: 'Source path or URL of the asset to import' + description: 'Source path or URL of the asset to import', }, targetPath: { type: 'string', - description: 'Target path within the Assets folder' + description: 'Target path within the Assets folder', }, importSettings: { type: 'object', properties: { - textureType: { type: 'string', description: 'Texture import type' }, + textureType: { + type: 'string', + description: 'Texture import type', + }, wrapMode: { type: 'string', description: 'Texture wrap mode' }, - filterMode: { type: 'string', description: 'Texture filter mode' }, + filterMode: { + type: 'string', + description: 'Texture filter mode', + }, maxSize: { type: 'number', description: 'Maximum texture size' }, - compression: { type: 'string', description: 'Compression format' } + compression: { + type: 'string', + description: 'Compression format', + }, }, description: 'Import settings for the asset', - additionalProperties: true - } + additionalProperties: true, + }, }, required: ['sourcePath'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('assets.import', args); return result; - } + }, }, { name: 'assets.createMaterial', - description: 'Create a new material with specified properties and shader.', + description: + 'Create a new material with specified properties and shader.', inputSchema: { type: 'object', properties: { name: { type: 'string', - description: 'Name of the material' + description: 'Name of the material', }, path: { type: 'string', - description: 'Path to save the material (relative to Assets folder)' + description: + 'Path to save the material (relative to Assets folder)', }, shader: { type: 'string', description: 'Shader name (e.g., "Standard", "Unlit/Color")', - default: 'Standard' + default: 'Standard', }, properties: { type: 'object', @@ -68,8 +80,8 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { r: { type: 'number', minimum: 0, maximum: 1 }, g: { type: 'number', minimum: 0, maximum: 1 }, b: { type: 'number', minimum: 0, maximum: 1 }, - a: { type: 'number', minimum: 0, maximum: 1 } - } + a: { type: 'number', minimum: 0, maximum: 1 }, + }, }, metallic: { type: 'number', minimum: 0, maximum: 1 }, smoothness: { type: 'number', minimum: 0, maximum: 1 }, @@ -78,19 +90,22 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { properties: { r: { type: 'number', minimum: 0 }, g: { type: 'number', minimum: 0 }, - b: { type: 'number', minimum: 0 } - } - } - } - } + b: { type: 'number', minimum: 0 }, + }, + }, + }, + }, }, required: ['name'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('assets.createMaterial', args); + const result = await unityClient.sendRequest( + 'assets.createMaterial', + args + ); return result; - } + }, }, { @@ -102,38 +117,42 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { action: { type: 'string', enum: ['create', 'modify', 'instantiate', 'update'], - description: 'Action to perform on prefabs' + description: 'Action to perform on prefabs', }, prefabPath: { type: 'string', - description: 'Path to the prefab asset' + description: 'Path to the prefab asset', }, gameObjectName: { type: 'string', - description: 'Name of GameObject to create prefab from (for create action)' + description: + 'Name of GameObject to create prefab from (for create action)', }, instantiatePosition: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, - z: { type: 'number' } + z: { type: 'number' }, }, - description: 'Position to instantiate prefab at' + description: 'Position to instantiate prefab at', }, modifications: { type: 'object', description: 'Property modifications for the prefab', - additionalProperties: true - } + additionalProperties: true, + }, }, required: ['action'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('assets.managePrefabs', args); + const result = await unityClient.sendRequest( + 'assets.managePrefabs', + args + ); return result; - } + }, }, { @@ -145,69 +164,71 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { action: { type: 'string', enum: ['move', 'copy', 'rename', 'delete', 'createFolder'], - description: 'Organization action to perform' + description: 'Organization action to perform', }, sourcePath: { type: 'string', - description: 'Source asset path' + description: 'Source asset path', }, targetPath: { type: 'string', - description: 'Target path for move/copy operations' + description: 'Target path for move/copy operations', }, newName: { type: 'string', - description: 'New name for rename operations' + description: 'New name for rename operations', }, recursive: { type: 'boolean', description: 'Whether to perform action recursively', - default: false - } + default: false, + }, }, required: ['action'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('assets.organize', args); return result; - } + }, }, { name: 'assets.search', - description: 'Search for assets in the project based on various criteria.', + description: + 'Search for assets in the project based on various criteria.', inputSchema: { type: 'object', properties: { query: { type: 'string', - description: 'Search query for asset names' + description: 'Search query for asset names', }, type: { type: 'string', - description: 'Asset type filter (e.g., "Texture2D", "Material", "Prefab")' + description: + 'Asset type filter (e.g., "Texture2D", "Material", "Prefab")', }, path: { type: 'string', - description: 'Path filter to search within specific folders' + description: 'Path filter to search within specific folders', }, label: { type: 'string', - description: 'Asset label filter' + description: 'Asset label filter', }, maxResults: { type: 'number', description: 'Maximum number of results to return', - default: 100 - } + default: 100, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('assets.search', args); return result; - } + }, }, { @@ -218,21 +239,21 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { properties: { name: { type: 'string', - description: 'Name of the texture' + description: 'Name of the texture', }, width: { type: 'number', - description: 'Texture width in pixels' + description: 'Texture width in pixels', }, height: { type: 'number', - description: 'Texture height in pixels' + description: 'Texture height in pixels', }, format: { type: 'string', enum: ['RGBA32', 'RGB24', 'ARGB32', 'Alpha8', 'R16', 'RGBAFloat'], description: 'Texture format', - default: 'RGBA32' + default: 'RGBA32', }, color: { type: 'object', @@ -240,62 +261,66 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { r: { type: 'number', minimum: 0, maximum: 1 }, g: { type: 'number', minimum: 0, maximum: 1 }, b: { type: 'number', minimum: 0, maximum: 1 }, - a: { type: 'number', minimum: 0, maximum: 1 } + a: { type: 'number', minimum: 0, maximum: 1 }, }, description: 'Fill color for the texture', - default: { r: 1, g: 1, b: 1, a: 1 } + default: { r: 1, g: 1, b: 1, a: 1 }, }, savePath: { type: 'string', - description: 'Path to save the texture (relative to Assets folder)' - } + description: 'Path to save the texture (relative to Assets folder)', + }, }, required: ['name', 'width', 'height'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('assets.createTexture', args); + const result = await unityClient.sendRequest( + 'assets.createTexture', + args + ); return result; - } + }, }, { name: 'packages.manage', - description: 'Manage Unity packages - install, update, or remove packages.', + description: + 'Manage Unity packages - install, update, or remove packages.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['install', 'update', 'remove', 'list'], - description: 'Package management action' + description: 'Package management action', }, packageName: { type: 'string', - description: 'Name of the package (for install/update/remove)' + description: 'Name of the package (for install/update/remove)', }, version: { type: 'string', - description: 'Specific version to install (optional)' + description: 'Specific version to install (optional)', }, source: { type: 'string', enum: ['registry', 'git', 'local'], description: 'Package source type', - default: 'registry' + default: 'registry', }, url: { type: 'string', - description: 'Package URL for git or local sources' - } + description: 'Package URL for git or local sources', + }, }, required: ['action'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('packages.manage', args); return result; - } + }, }, { @@ -306,29 +331,29 @@ export function createAssetTools(unityClient: UnityClient): Tool[] { properties: { assetPath: { type: 'string', - description: 'Path to the asset (relative to Assets folder)' + description: 'Path to the asset (relative to Assets folder)', }, guid: { type: 'string', - description: 'GUID of the asset' + description: 'GUID of the asset', }, includeMetadata: { type: 'boolean', description: 'Whether to include asset metadata', - default: true + default: true, }, includeDependencies: { type: 'boolean', description: 'Whether to include asset dependencies', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('assets.getInfo', args); return result; - } - } + }, + }, ]; -} \ No newline at end of file +} diff --git a/Server/src/tools/build-tools.ts b/Server/src/tools/build-tools.ts index b090c2c..af112f2 100644 --- a/Server/src/tools/build-tools.ts +++ b/Server/src/tools/build-tools.ts @@ -5,39 +5,48 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { return [ { name: 'build.configure', - description: 'Configure build settings for Unity project including platform, scenes, and player settings.', + description: + 'Configure build settings for Unity project including platform, scenes, and player settings.', inputSchema: { type: 'object', properties: { target: { type: 'string', - enum: ['StandaloneWindows64', 'StandaloneOSX', 'StandaloneLinux64', 'iOS', 'Android', 'WebGL', 'WSAPlayer'], - description: 'Target build platform' + enum: [ + 'StandaloneWindows64', + 'StandaloneOSX', + 'StandaloneLinux64', + 'iOS', + 'Android', + 'WebGL', + 'WSAPlayer', + ], + description: 'Target build platform', }, scenes: { type: 'array', items: { type: 'string' }, - description: 'List of scene paths to include in build' + description: 'List of scene paths to include in build', }, buildPath: { type: 'string', - description: 'Output path for the build' + description: 'Output path for the build', }, development: { type: 'boolean', description: 'Whether to create a development build', - default: false + default: false, }, scriptDebugging: { type: 'boolean', description: 'Enable script debugging in development builds', - default: false + default: false, }, compressionType: { type: 'string', enum: ['None', 'Lz4', 'Lz4HC'], description: 'Asset bundle compression type', - default: 'Lz4' + default: 'Lz4', }, playerSettings: { type: 'object', @@ -46,61 +55,63 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { productName: { type: 'string' }, version: { type: 'string' }, bundleVersion: { type: 'string' }, - applicationIdentifier: { type: 'string' } + applicationIdentifier: { type: 'string' }, }, description: 'Player settings to apply before build', - additionalProperties: true - } + additionalProperties: true, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.configure', args); return result; - } + }, }, { name: 'build.execute', - description: 'Execute a build with current settings or specified configuration.', + description: + 'Execute a build with current settings or specified configuration.', inputSchema: { type: 'object', properties: { target: { type: 'string', - description: 'Override build target platform' + description: 'Override build target platform', }, outputPath: { type: 'string', - description: 'Override output path for this build' + description: 'Override output path for this build', }, clean: { type: 'boolean', description: 'Clean build cache before building', - default: false + default: false, }, strictMode: { type: 'boolean', description: 'Enable strict build mode (treat warnings as errors)', - default: false + default: false, }, showBuiltPlayer: { type: 'boolean', description: 'Show built player after successful build', - default: true - } + default: true, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.execute', args); return result; - } + }, }, { name: 'build.runTests', - description: 'Run Unity tests (EditMode and/or PlayMode) and return results.', + description: + 'Run Unity tests (EditMode and/or PlayMode) and return results.', inputSchema: { type: 'object', properties: { @@ -108,7 +119,7 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { type: 'string', enum: ['EditMode', 'PlayMode', 'Both'], description: 'Test mode to run', - default: 'Both' + default: 'Both', }, filter: { type: 'object', @@ -116,76 +127,77 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { testNames: { type: 'array', items: { type: 'string' }, - description: 'Specific test names to run' + description: 'Specific test names to run', }, categories: { type: 'array', items: { type: 'string' }, - description: 'Test categories to include' + description: 'Test categories to include', }, assemblies: { type: 'array', items: { type: 'string' }, - description: 'Test assemblies to run' - } + description: 'Test assemblies to run', + }, }, - description: 'Filters for test execution' + description: 'Filters for test execution', }, buildTarget: { type: 'string', - description: 'Target platform for PlayMode tests' + description: 'Target platform for PlayMode tests', }, timeout: { type: 'number', description: 'Timeout for test execution in seconds', - default: 300 + default: 300, }, generateReport: { type: 'boolean', description: 'Whether to generate a test report', - default: true + default: true, }, reportPath: { type: 'string', - description: 'Path to save the test report' - } + description: 'Path to save the test report', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.runTests', args); return result; - } + }, }, { name: 'build.getReport', - description: 'Get detailed information about the last build including size, warnings, and errors.', + description: + 'Get detailed information about the last build including size, warnings, and errors.', inputSchema: { type: 'object', properties: { includeAssets: { type: 'boolean', description: 'Whether to include asset information in the report', - default: true + default: true, }, includeSteps: { type: 'boolean', description: 'Whether to include build step information', - default: true + default: true, }, includeFiles: { type: 'boolean', description: 'Whether to include file list', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.getReport', args); return result; - } + }, }, { @@ -198,20 +210,20 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { type: 'string', enum: ['all', 'library', 'temp', 'builds', 'logs'], description: 'Type of cleanup to perform', - default: 'all' + default: 'all', }, confirmClean: { type: 'boolean', description: 'Confirmation that user wants to clean (safety check)', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.clean', args); return result; - } + }, }, { @@ -222,83 +234,88 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { properties: { buildTarget: { type: 'string', - description: 'Target platform for Addressables build' + description: 'Target platform for Addressables build', }, buildPath: { type: 'string', - description: 'Path for Addressables build output' + description: 'Path for Addressables build output', }, clearCache: { type: 'boolean', description: 'Whether to clear the cache before building', - default: false + default: false, }, buildPlayerContent: { type: 'boolean', description: 'Whether to build player content', - default: true + default: true, }, buildRemoteContent: { type: 'boolean', description: 'Whether to build remote content', - default: true + default: true, }, profile: { type: 'string', - description: 'Addressables profile to use for the build' - } + description: 'Addressables profile to use for the build', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('build.addressables', args); + const result = await unityClient.sendRequest( + 'build.addressables', + args + ); return result; - } + }, }, { name: 'build.optimize', - description: 'Analyze and optimize build settings for size and performance.', + description: + 'Analyze and optimize build settings for size and performance.', inputSchema: { type: 'object', properties: { target: { type: 'string', - description: 'Target platform to optimize for' + description: 'Target platform to optimize for', }, optimizationType: { type: 'string', enum: ['size', 'performance', 'balanced'], description: 'Type of optimization to prioritize', - default: 'balanced' + default: 'balanced', }, analyzeAssets: { type: 'boolean', description: 'Whether to analyze asset usage and sizes', - default: true + default: true, }, suggestChanges: { type: 'boolean', description: 'Whether to suggest optimization changes', - default: true + default: true, }, applyChanges: { type: 'boolean', description: 'Whether to automatically apply safe optimizations', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.optimize', args); return result; - } + }, }, { name: 'build.getConsoleLogs', - description: 'Retrieve console logs from Unity Editor including errors, warnings, and debug messages.', + description: + 'Retrieve console logs from Unity Editor including errors, warnings, and debug messages.', inputSchema: { type: 'object', properties: { @@ -306,32 +323,35 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { type: 'array', items: { type: 'string', - enum: ['Log', 'Warning', 'Error', 'Assert', 'Exception'] + enum: ['Log', 'Warning', 'Error', 'Assert', 'Exception'], }, description: 'Types of logs to retrieve', - default: ['Log', 'Warning', 'Error'] + default: ['Log', 'Warning', 'Error'], }, limit: { type: 'number', description: 'Maximum number of log entries to return', - default: 100 + default: 100, }, since: { type: 'string', - description: 'ISO timestamp to get logs since (optional)' + description: 'ISO timestamp to get logs since (optional)', }, clearAfterRetrieve: { type: 'boolean', description: 'Whether to clear logs after retrieving them', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('build.getConsoleLogs', args); + const result = await unityClient.sendRequest( + 'build.getConsoleLogs', + args + ); return result; - } + }, }, { @@ -342,33 +362,39 @@ export function createBuildTools(unityClient: UnityClient): Tool[] { properties: { target: { type: 'string', - description: 'Target platform to profile' + description: 'Target platform to profile', }, profileSteps: { type: 'array', items: { type: 'string', - enum: ['preprocessing', 'compilation', 'assetprocessing', 'linking', 'postprocessing'] + enum: [ + 'preprocessing', + 'compilation', + 'assetprocessing', + 'linking', + 'postprocessing', + ], }, description: 'Build steps to profile', - default: ['compilation', 'assetprocessing'] + default: ['compilation', 'assetprocessing'], }, generateReport: { type: 'boolean', description: 'Whether to generate a detailed profiling report', - default: true + default: true, }, reportPath: { type: 'string', - description: 'Path to save the profiling report' - } + description: 'Path to save the profiling report', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('build.profile', args); return result; - } - } + }, + }, ]; -} \ No newline at end of file +} diff --git a/Server/src/tools/code-tools.ts b/Server/src/tools/code-tools.ts index a83cc60..7202335 100644 --- a/Server/src/tools/code-tools.ts +++ b/Server/src/tools/code-tools.ts @@ -5,27 +5,36 @@ export function createCodeTools(unityClient: UnityClient): Tool[] { return [ { name: 'code.createScript', - description: 'Generate C# scripts with templates for common Unity patterns like MonoBehaviour, ScriptableObject, or custom classes.', + description: + 'Generate C# scripts with templates for common Unity patterns like MonoBehaviour, ScriptableObject, or custom classes.', inputSchema: { type: 'object', properties: { name: { type: 'string', - description: 'Name of the script class' + description: 'Name of the script class', }, template: { type: 'string', - enum: ['MonoBehaviour', 'ScriptableObject', 'Interface', 'Enum', 'Class', 'Singleton', 'StateMachine'], + enum: [ + 'MonoBehaviour', + 'ScriptableObject', + 'Interface', + 'Enum', + 'Class', + 'Singleton', + 'StateMachine', + ], description: 'Template type to use', - default: 'MonoBehaviour' + default: 'MonoBehaviour', }, namespace: { type: 'string', - description: 'Namespace for the script' + description: 'Namespace for the script', }, savePath: { type: 'string', - description: 'Path to save the script (relative to Assets folder)' + description: 'Path to save the script (relative to Assets folder)', }, methods: { type: 'array', @@ -40,18 +49,18 @@ export function createCodeTools(unityClient: UnityClient): Tool[] { type: 'object', properties: { name: { type: 'string' }, - type: { type: 'string' } + type: { type: 'string' }, }, - required: ['name', 'type'] - } + required: ['name', 'type'], + }, }, isPublic: { type: 'boolean', default: true }, isVirtual: { type: 'boolean', default: false }, - body: { type: 'string', description: 'Method body code' } + body: { type: 'string', description: 'Method body code' }, }, - required: ['name'] + required: ['name'], }, - description: 'Methods to include in the script' + description: 'Methods to include in the script', }, properties: { type: 'array', @@ -64,73 +73,84 @@ export function createCodeTools(unityClient: UnityClient): Tool[] { isSerializeField: { type: 'boolean', default: false }, hasGetter: { type: 'boolean', default: true }, hasSetter: { type: 'boolean', default: true }, - defaultValue: { type: 'string' } + defaultValue: { type: 'string' }, }, - required: ['name', 'type'] + required: ['name', 'type'], }, - description: 'Properties and fields to include' + description: 'Properties and fields to include', }, usings: { type: 'array', items: { type: 'string' }, - description: 'Additional using statements' + description: 'Additional using statements', }, baseClass: { type: 'string', - description: 'Base class to inherit from (if not using template default)' + description: + 'Base class to inherit from (if not using template default)', }, interfaces: { type: 'array', items: { type: 'string' }, - description: 'Interfaces to implement' - } + description: 'Interfaces to implement', + }, }, required: ['name'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('code.createScript', args); return result; - } + }, }, { name: 'code.analyzeScripts', - description: 'Analyze existing C# scripts in the project to understand code structure, dependencies, and patterns.', + description: + 'Analyze existing C# scripts in the project to understand code structure, dependencies, and patterns.', inputSchema: { type: 'object', properties: { scriptPath: { type: 'string', - description: 'Path to specific script to analyze' + description: 'Path to specific script to analyze', }, directory: { type: 'string', - description: 'Directory to analyze all scripts in' + description: 'Directory to analyze all scripts in', }, analysisType: { type: 'string', - enum: ['dependencies', 'complexity', 'patterns', 'documentation', 'all'], + enum: [ + 'dependencies', + 'complexity', + 'patterns', + 'documentation', + 'all', + ], description: 'Type of analysis to perform', - default: 'all' + default: 'all', }, includeComments: { type: 'boolean', description: 'Whether to include comment analysis', - default: false + default: false, }, findUnusedCode: { type: 'boolean', description: 'Whether to identify unused methods and variables', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('code.analyzeScripts', args); + const result = await unityClient.sendRequest( + 'code.analyzeScripts', + args + ); return result; - } + }, }, { @@ -141,251 +161,275 @@ export function createCodeTools(unityClient: UnityClient): Tool[] { properties: { gameObjectName: { type: 'string', - description: 'Name of the target GameObject' + description: 'Name of the target GameObject', }, gameObjectId: { type: 'number', - description: 'Instance ID of the target GameObject' + description: 'Instance ID of the target GameObject', }, scriptPath: { type: 'string', - description: 'Path to the script file' + description: 'Path to the script file', }, scriptName: { type: 'string', - description: 'Name of the script class' + description: 'Name of the script class', }, configureProperties: { type: 'object', description: 'Properties to set on the script component', - additionalProperties: true - } + additionalProperties: true, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('code.attachScript', args); return result; - } + }, }, { name: 'code.findReferences', - description: 'Find references to scripts, components, or specific code elements throughout the project.', + description: + 'Find references to scripts, components, or specific code elements throughout the project.', inputSchema: { type: 'object', properties: { searchType: { type: 'string', enum: ['script', 'method', 'property', 'variable', 'type'], - description: 'Type of element to search for' + description: 'Type of element to search for', }, searchTerm: { type: 'string', - description: 'Name of the element to find references for' + description: 'Name of the element to find references for', }, scope: { type: 'string', enum: ['project', 'scene', 'directory'], description: 'Scope of the search', - default: 'project' + default: 'project', }, directory: { type: 'string', - description: 'Directory to limit search to (if scope is directory)' + description: 'Directory to limit search to (if scope is directory)', }, includeComments: { type: 'boolean', description: 'Whether to include references in comments', - default: false + default: false, }, caseSensitive: { type: 'boolean', description: 'Whether the search should be case sensitive', - default: true - } + default: true, + }, }, required: ['searchType', 'searchTerm'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('code.findReferences', args); + const result = await unityClient.sendRequest( + 'code.findReferences', + args + ); return result; - } + }, }, { name: 'code.refactor', - description: 'Perform code refactoring operations like renaming, extracting methods, or reorganizing code.', + description: + 'Perform code refactoring operations like renaming, extracting methods, or reorganizing code.', inputSchema: { type: 'object', properties: { operation: { type: 'string', - enum: ['rename', 'extractMethod', 'inlineMethod', 'moveMethod', 'addInterface'], - description: 'Refactoring operation to perform' + enum: [ + 'rename', + 'extractMethod', + 'inlineMethod', + 'moveMethod', + 'addInterface', + ], + description: 'Refactoring operation to perform', }, scriptPath: { type: 'string', - description: 'Path to the script to refactor' + description: 'Path to the script to refactor', }, oldName: { type: 'string', - description: 'Current name (for rename operations)' + description: 'Current name (for rename operations)', }, newName: { type: 'string', - description: 'New name (for rename operations)' + description: 'New name (for rename operations)', }, methodName: { type: 'string', - description: 'Method name for method-related operations' + description: 'Method name for method-related operations', }, startLine: { type: 'number', - description: 'Start line for code selection (for extract operations)' + description: + 'Start line for code selection (for extract operations)', }, endLine: { type: 'number', - description: 'End line for code selection (for extract operations)' + description: 'End line for code selection (for extract operations)', }, targetClass: { type: 'string', - description: 'Target class for move operations' + description: 'Target class for move operations', }, interfaceName: { type: 'string', - description: 'Interface name for interface operations' - } + description: 'Interface name for interface operations', + }, }, required: ['operation', 'scriptPath'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('code.refactor', args); return result; - } + }, }, { name: 'code.generateDocumentation', - description: 'Generate or update XML documentation comments for C# scripts.', + description: + 'Generate or update XML documentation comments for C# scripts.', inputSchema: { type: 'object', properties: { scriptPath: { type: 'string', - description: 'Path to the script to document' + description: 'Path to the script to document', }, directory: { type: 'string', - description: 'Directory to document all scripts in' + description: 'Directory to document all scripts in', }, includePrivate: { type: 'boolean', description: 'Whether to document private members', - default: false + default: false, }, updateExisting: { type: 'boolean', description: 'Whether to update existing documentation', - default: true + default: true, }, format: { type: 'string', enum: ['xml', 'markdown', 'html'], description: 'Documentation format', - default: 'xml' - } + default: 'xml', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('code.generateDocumentation', args); + const result = await unityClient.sendRequest( + 'code.generateDocumentation', + args + ); return result; - } + }, }, { name: 'code.validate', - description: 'Validate C# scripts for common issues, style violations, and potential bugs.', + description: + 'Validate C# scripts for common issues, style violations, and potential bugs.', inputSchema: { type: 'object', properties: { scriptPath: { type: 'string', - description: 'Path to specific script to validate' + description: 'Path to specific script to validate', }, directory: { type: 'string', - description: 'Directory to validate all scripts in' + description: 'Directory to validate all scripts in', }, checks: { type: 'array', items: { type: 'string', - enum: ['syntax', 'style', 'performance', 'security', 'unity-specific'] + enum: [ + 'syntax', + 'style', + 'performance', + 'security', + 'unity-specific', + ], }, description: 'Types of validation checks to perform', - default: ['syntax', 'style', 'unity-specific'] + default: ['syntax', 'style', 'unity-specific'], }, severity: { type: 'string', enum: ['all', 'error', 'warning', 'info'], description: 'Minimum severity level to report', - default: 'warning' - } + default: 'warning', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('code.validate', args); return result; - } + }, }, { name: 'code.format', - description: 'Format C# scripts according to coding standards and style guidelines.', + description: + 'Format C# scripts according to coding standards and style guidelines.', inputSchema: { type: 'object', properties: { scriptPath: { type: 'string', - description: 'Path to specific script to format' + description: 'Path to specific script to format', }, directory: { type: 'string', - description: 'Directory to format all scripts in' + description: 'Directory to format all scripts in', }, style: { type: 'string', enum: ['microsoft', 'unity', 'custom'], description: 'Coding style to apply', - default: 'unity' + default: 'unity', }, indentSize: { type: 'number', description: 'Number of spaces for indentation', - default: 4 + default: 4, }, useTabs: { type: 'boolean', description: 'Whether to use tabs instead of spaces', - default: false + default: false, }, preserveComments: { type: 'boolean', description: 'Whether to preserve comment formatting', - default: true - } + default: true, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('code.format', args); return result; - } - } + }, + }, ]; -} \ No newline at end of file +} diff --git a/Server/src/tools/lab-tools.ts b/Server/src/tools/lab-tools.ts index 67ec48b..50028be 100644 --- a/Server/src/tools/lab-tools.ts +++ b/Server/src/tools/lab-tools.ts @@ -20,7 +20,9 @@ export function createLabTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute() { - const result = await unityClient.sendRequest('lab', { action: 'status' }); + const result = await unityClient.sendRequest('lab', { + action: 'status', + }); return result; }, }, @@ -28,30 +30,34 @@ export function createLabTools(unityClient: UnityClient): Tool[] { { name: 'lab.create_experiment', description: - 'Define a new rigorous experiment. Specify the hypothesis, the parameters you want to vary, their ranges, how many trials to run, and what input behavior to test. The experiment is grounded against your project\'s living design document and past insights.', + "Define a new rigorous experiment. Specify the hypothesis, the parameters you want to vary, their ranges, how many trials to run, and what input behavior to test. The experiment is grounded against your project's living design document and past insights.", inputSchema: { type: 'object', properties: { experimentName: { type: 'string', - description: 'Human-readable name for the experiment (e.g. "Jump Responsiveness v3")', + description: + 'Human-readable name for the experiment (e.g. "Jump Responsiveness v3")', }, hypothesis: { type: 'string', - description: 'What you are trying to prove or improve (e.g. "Increasing coyote time to 0.2s will improve perceived fairness without hurting precision")', + description: + 'What you are trying to prove or improve (e.g. "Increasing coyote time to 0.2s will improve perceived fairness without hurting precision")', }, parameters: { type: 'array', items: { type: 'string' }, - description: 'Paths to the parameters being varied (e.g. ["PlayerController.coyoteTime", "PlayerController.jumpForce"])', + description: + 'Paths to the parameters being varied (e.g. ["PlayerController.coyoteTime", "PlayerController.jumpForce"])', }, valueRanges: { type: 'array', items: { type: 'array', - items: { type: 'number' } + items: { type: 'number' }, }, - description: 'Parallel array of [min, max] ranges for each parameter', + description: + 'Parallel array of [min, max] ranges for each parameter', }, trialCount: { type: 'number', @@ -148,4 +154,4 @@ export function createLabTools(unityClient: UnityClient): Tool[] { }, }, ]; -} \ No newline at end of file +} diff --git a/Server/src/tools/memory-tools.ts b/Server/src/tools/memory-tools.ts index b503ef4..931b324 100644 --- a/Server/src/tools/memory-tools.ts +++ b/Server/src/tools/memory-tools.ts @@ -18,7 +18,9 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute() { - const result = await unityClient.sendRequest('project_memory', { action: 'status' }); + const result = await unityClient.sendRequest('project_memory', { + action: 'status', + }); return result; }, }, @@ -32,12 +34,14 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { properties: { includeScripts: { type: 'boolean', - description: 'Whether to scan C# scripts for design intent and summaries', + description: + 'Whether to scan C# scripts for design intent and summaries', default: true, }, includeDesignDocs: { type: 'boolean', - description: 'Whether to index design documents and specs found in the project', + description: + 'Whether to index design documents and specs found in the project', default: true, }, }, @@ -61,7 +65,8 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { properties: { query: { type: 'string', - description: 'Natural language question about the project (e.g. "What are the current jump parameters and why?", "How does damage calculation work?")', + description: + 'Natural language question about the project (e.g. "What are the current jump parameters and why?", "How does damage calculation work?")', }, maxResults: { type: 'number', @@ -90,7 +95,8 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { properties: { insightType: { type: 'string', - description: 'Category of insight: "observation", "design_decision", "gotcha", "performance_note", "vision"', + description: + 'Category of insight: "observation", "design_decision", "gotcha", "performance_note", "vision"', default: 'observation', }, title: { @@ -99,11 +105,13 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { }, content: { type: 'string', - description: 'The actual finding, decision, or measured result. Be specific and quantitative when possible.', + description: + 'The actual finding, decision, or measured result. Be specific and quantitative when possible.', }, relatedTo: { type: 'string', - description: 'What part of the game this relates to (e.g. "PlayerController", "Jump Mechanics", "Enemy AI")', + description: + 'What part of the game this relates to (e.g. "PlayerController", "Jump Mechanics", "Enemy AI")', }, }, required: ['content'], @@ -128,7 +136,9 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute() { - const result = await unityClient.sendRequest('project_memory', { action: 'get_design_doc' }); + const result = await unityClient.sendRequest('project_memory', { + action: 'get_design_doc', + }); return result; }, }, @@ -142,7 +152,8 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { properties: { content: { type: 'string', - description: 'The complete new content of the living design document (markdown).', + description: + 'The complete new content of the living design document (markdown).', }, }, required: ['content'], @@ -166,11 +177,13 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { properties: { goal: { type: 'string', - description: 'The concrete improvement or design goal you want advice on (be specific).', + description: + 'The concrete improvement or design goal you want advice on (be specific).', }, focusArea: { type: 'string', - description: 'Optional focus area (e.g. "movement", "combat", "camera", "progression")', + description: + 'Optional focus area (e.g. "movement", "combat", "camera", "progression")', }, }, required: ['goal'], @@ -185,4 +198,4 @@ export function createMemoryTools(unityClient: UnityClient): Tool[] { }, }, ]; -} \ No newline at end of file +} diff --git a/Server/src/tools/playmode-tools.ts b/Server/src/tools/playmode-tools.ts index 80c5aa3..3f969bc 100644 --- a/Server/src/tools/playmode-tools.ts +++ b/Server/src/tools/playmode-tools.ts @@ -27,7 +27,8 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { properties: { maximizeGameView: { type: 'boolean', - description: 'Whether to maximize the Game view when entering Play Mode', + description: + 'Whether to maximize the Game view when entering Play Mode', default: false, }, }, @@ -92,7 +93,8 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { properties: { frames: { type: 'number', - description: 'Number of frames to step (default 1). The game pauses after stepping.', + description: + 'Number of frames to step (default 1). The game pauses after stepping.', default: 1, }, }, @@ -107,7 +109,7 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { { name: 'playmode.inspectGameObject', description: - 'Inspect a GameObject\'s runtime state during Play Mode. Returns live component values (transforms, physics velocities, custom script fields) — not just the serialized editor values. Essential for debugging runtime behavior.', + "Inspect a GameObject's runtime state during Play Mode. Returns live component values (transforms, physics velocities, custom script fields) — not just the serialized editor values. Essential for debugging runtime behavior.", inputSchema: { type: 'object', properties: { @@ -127,14 +129,18 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { }, includeChildren: { type: 'boolean', - description: 'Whether to include child GameObjects in the inspection', + description: + 'Whether to include child GameObjects in the inspection', default: false, }, }, additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('playmode.inspectGameObject', args); + const result = await unityClient.sendRequest( + 'playmode.inspectGameObject', + args + ); return result; }, }, @@ -156,11 +162,13 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { }, componentType: { type: 'string', - description: 'Type of the component to modify (e.g., "Transform", "Rigidbody", "PlayerController")', + description: + 'Type of the component to modify (e.g., "Transform", "Rigidbody", "PlayerController")', }, propertyName: { type: 'string', - description: 'Name of the property or field to set (e.g., "position", "velocity", "speed")', + description: + 'Name of the property or field to set (e.g., "position", "velocity", "speed")', }, value: { description: @@ -171,7 +179,10 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('playmode.setProperty', args); + const result = await unityClient.sendRequest( + 'playmode.setProperty', + args + ); return result; }, }, @@ -209,7 +220,10 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('playmode.invokeMethod', args); + const result = await unityClient.sendRequest( + 'playmode.invokeMethod', + args + ); return result; }, }, @@ -237,7 +251,8 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { }, sinceLastCall: { type: 'boolean', - description: 'Only return logs since the last call to this tool (useful for incremental monitoring)', + description: + 'Only return logs since the last call to this tool (useful for incremental monitoring)', default: false, }, search: { @@ -248,7 +263,10 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('playmode.getConsoleLogs', args); + const result = await unityClient.sendRequest( + 'playmode.getConsoleLogs', + args + ); return result; }, }, @@ -267,7 +285,8 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { }, includePhysics: { type: 'boolean', - description: 'Include physics simulation info (contacts, rigidbody count)', + description: + 'Include physics simulation info (contacts, rigidbody count)', default: true, }, includeTime: { @@ -279,7 +298,10 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('playmode.getRuntimeInfo', args); + const result = await unityClient.sendRequest( + 'playmode.getRuntimeInfo', + args + ); return result; }, }, @@ -304,7 +326,8 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { }, instanceId: { type: 'number', - description: 'Instance ID of the GameObject (alternative to name)', + description: + 'Instance ID of the GameObject (alternative to name)', }, componentType: { type: 'string', @@ -345,7 +368,8 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { }, holdDuration: { type: 'number', - description: 'How long to hold the key in seconds (default: same as observation duration)', + description: + 'How long to hold the key in seconds (default: same as observation duration)', }, }, required: ['key'], @@ -357,7 +381,11 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { async execute(args: Record) { const duration = (args.duration as number) ?? 2.0; const timeoutMs = (duration + 5) * 1000; - const result = await unityClient.sendRequest('playmode.observe', args, timeoutMs); + const result = await unityClient.sendRequest( + 'playmode.observe', + args, + timeoutMs + ); return result; }, }, @@ -394,7 +422,11 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { async execute(args: Record) { const holdDuration = (args.holdDuration as number) ?? 0.1; const timeoutMs = (holdDuration + 5) * 1000; - const result = await unityClient.sendRequest('playmode.simulateInput', args, timeoutMs); + const result = await unityClient.sendRequest( + 'playmode.simulateInput', + args, + timeoutMs + ); return result; }, }, @@ -408,14 +440,18 @@ export function createPlayModeTools(unityClient: UnityClient): Tool[] { properties: { timeScale: { type: 'number', - description: 'Time scale value (0 = frozen, 1 = normal, 2 = double speed)', + description: + 'Time scale value (0 = frozen, 1 = normal, 2 = double speed)', }, }, required: ['timeScale'], additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('playmode.setTimeScale', args); + const result = await unityClient.sendRequest( + 'playmode.setTimeScale', + args + ); return result; }, }, diff --git a/Server/src/tools/project-tools.ts b/Server/src/tools/project-tools.ts index 7cbc700..7cc7123 100644 --- a/Server/src/tools/project-tools.ts +++ b/Server/src/tools/project-tools.ts @@ -5,122 +5,132 @@ export function createProjectTools(unityClient: UnityClient): Tool[] { return [ { name: 'project.analyze', - description: 'Analyze Unity project structure and return comprehensive information about the project, including scenes, assets, packages, and settings.', + description: + 'Analyze Unity project structure and return comprehensive information about the project, including scenes, assets, packages, and settings.', inputSchema: { type: 'object', properties: { includeAssets: { type: 'boolean', description: 'Whether to include detailed asset information', - default: false + default: false, }, includePackages: { - type: 'boolean', + type: 'boolean', description: 'Whether to include package information', - default: true - } + default: true, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const { includeAssets = false, includePackages = true } = args; - + const result = await unityClient.sendRequest('project.analyze', { includeAssets, - includePackages + includePackages, }); - + return result; - } + }, }, { name: 'project.getInfo', - description: 'Get basic information about the Unity project including name, version, platform, and loaded scenes.', + description: + 'Get basic information about the Unity project including name, version, platform, and loaded scenes.', inputSchema: { type: 'object', properties: {}, - additionalProperties: false + additionalProperties: false, }, async execute() { const result = await unityClient.sendRequest('project.getInfo'); return result; - } + }, }, { name: 'project.setSettings', - description: 'Modify Unity project settings such as company name, product name, version, or other player settings.', + description: + 'Modify Unity project settings such as company name, product name, version, or other player settings.', inputSchema: { type: 'object', properties: { companyName: { type: 'string', - description: 'Company name in player settings' + description: 'Company name in player settings', }, productName: { type: 'string', - description: 'Product name in player settings' + description: 'Product name in player settings', }, version: { type: 'string', - description: 'Application version' + description: 'Application version', }, bundleVersion: { type: 'string', - description: 'Bundle version for mobile platforms' + description: 'Bundle version for mobile platforms', }, settings: { type: 'object', description: 'Additional player settings as key-value pairs', - additionalProperties: true - } + additionalProperties: true, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('project.setSettings', args); + const result = await unityClient.sendRequest( + 'project.setSettings', + args + ); return result; - } + }, }, { name: 'project.listScenes', - description: 'List all scenes in the project, including their build settings and current load status.', + description: + 'List all scenes in the project, including their build settings and current load status.', inputSchema: { type: 'object', properties: { includeDisabled: { type: 'boolean', description: 'Whether to include scenes not in build settings', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const { includeDisabled = false } = args; - + const result = await unityClient.sendRequest('project.listScenes', { - includeDisabled + includeDisabled, }); - + return result; - } + }, }, { name: 'project.getBuildSettings', - description: 'Get current build settings including target platform, scenes in build, and player settings.', + description: + 'Get current build settings including target platform, scenes in build, and player settings.', inputSchema: { type: 'object', properties: {}, - additionalProperties: false + additionalProperties: false, }, async execute() { - const result = await unityClient.sendRequest('project.getBuildSettings'); + const result = await unityClient.sendRequest( + 'project.getBuildSettings' + ); return result; - } + }, }, { @@ -131,47 +141,56 @@ export function createProjectTools(unityClient: UnityClient): Tool[] { properties: { target: { type: 'string', - enum: ['StandaloneWindows64', 'StandaloneOSX', 'StandaloneLinux64', 'iOS', 'Android', 'WebGL', 'WSAPlayer'], - description: 'Target platform for builds' - } + enum: [ + 'StandaloneWindows64', + 'StandaloneOSX', + 'StandaloneLinux64', + 'iOS', + 'Android', + 'WebGL', + 'WSAPlayer', + ], + description: 'Target platform for builds', + }, }, required: ['target'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const { target } = args; - + const result = await unityClient.sendRequest('project.setBuildTarget', { - target + target, }); - + return result; - } + }, }, { name: 'project.refreshAssets', - description: 'Refresh the Unity Asset Database to detect changes in project files.', + description: + 'Refresh the Unity Asset Database to detect changes in project files.', inputSchema: { type: 'object', properties: { forceReimport: { type: 'boolean', description: 'Whether to force reimport all assets', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const { forceReimport = false } = args; - + const result = await unityClient.sendRequest('project.refreshAssets', { - forceReimport + forceReimport, }); - + return result; - } - } + }, + }, ]; -} \ No newline at end of file +} diff --git a/Server/src/tools/scene-tools.ts b/Server/src/tools/scene-tools.ts index 5edeb32..85ec487 100644 --- a/Server/src/tools/scene-tools.ts +++ b/Server/src/tools/scene-tools.ts @@ -5,133 +5,223 @@ export function createSceneTools(unityClient: UnityClient): Tool[] { return [ { name: 'scene.createGameObject', - description: 'Create a new GameObject in the current scene with specified name, position, rotation, and components.', + description: + 'Create a new GameObject in the current scene with specified name, position, rotation, and components.', inputSchema: { type: 'object', properties: { name: { type: 'string', - description: 'Name of the GameObject' + description: 'Name of the GameObject', }, parent: { type: 'string', - description: 'Name or instance ID of parent GameObject' + description: 'Name or instance ID of parent GameObject', }, position: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, - z: { type: 'number' } + z: { type: 'number' }, }, - description: 'World position of the GameObject' + description: 'World position of the GameObject', }, rotation: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, - z: { type: 'number' } + z: { type: 'number' }, }, - description: 'Euler rotation of the GameObject' + description: 'Euler rotation of the GameObject', }, scale: { type: 'object', properties: { x: { type: 'number', default: 1 }, y: { type: 'number', default: 1 }, - z: { type: 'number', default: 1 } + z: { type: 'number', default: 1 }, }, - description: 'Scale of the GameObject' + description: 'Scale of the GameObject', + }, + primitive: { + type: 'string', + enum: ['Cube', 'Sphere', 'Capsule', 'Cylinder', 'Plane', 'Quad'], + description: + 'Optional Unity primitive to create (adds mesh, renderer, and collider)', }, components: { type: 'array', items: { type: 'string' }, - description: 'List of component types to add' + description: 'List of component types to add', }, tag: { type: 'string', - description: 'Tag to assign to the GameObject' + description: 'Tag to assign to the GameObject', }, layer: { type: 'number', - description: 'Layer to assign to the GameObject' - } + description: 'Layer to assign to the GameObject', + }, }, required: ['name'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('scene.createGameObject', args); + const result = await unityClient.sendRequest( + 'scene.createGameObject', + args + ); return result; - } + }, + }, + + { + name: 'scene.createPrimitive', + description: + 'Create a Unity primitive GameObject (Cube, Sphere, Capsule, Cylinder, Plane, or Quad) with mesh, renderer, and collider.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the GameObject', + }, + primitiveType: { + type: 'string', + enum: ['Cube', 'Sphere', 'Capsule', 'Cylinder', 'Plane', 'Quad'], + description: 'Primitive shape to create', + }, + parent: { + type: 'string', + description: 'Name or instance ID of parent GameObject', + }, + position: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + z: { type: 'number' }, + }, + description: 'World position of the GameObject', + }, + rotation: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + z: { type: 'number' }, + }, + description: 'Euler rotation of the GameObject', + }, + scale: { + type: 'object', + properties: { + x: { type: 'number', default: 1 }, + y: { type: 'number', default: 1 }, + z: { type: 'number', default: 1 }, + }, + description: 'Scale of the GameObject', + }, + components: { + type: 'array', + items: { type: 'string' }, + description: 'Additional component types to add', + }, + tag: { + type: 'string', + description: 'Tag to assign to the GameObject', + }, + layer: { + type: 'number', + description: 'Layer to assign to the GameObject', + }, + }, + required: ['primitiveType'], + additionalProperties: false, + }, + async execute(args: Record) { + const result = await unityClient.sendRequest( + 'scene.createPrimitive', + args + ); + return result; + }, }, { name: 'scene.modifyComponent', - description: 'Add, remove, or modify components on GameObjects in the scene.', + description: + 'Add, remove, or modify components on GameObjects in the scene.', inputSchema: { type: 'object', properties: { gameObjectName: { type: 'string', - description: 'Name of the GameObject to modify' + description: 'Name of the GameObject to modify', }, instanceId: { type: 'number', - description: 'Instance ID of the GameObject (alternative to name)' + description: 'Instance ID of the GameObject (alternative to name)', }, componentType: { type: 'string', - description: 'Type of component to add/modify (e.g., "Transform", "Rigidbody")' + description: + 'Type of component to add/modify (e.g., "Transform", "Rigidbody")', }, action: { type: 'string', enum: ['add', 'remove', 'modify'], - description: 'Action to perform on the component' + description: 'Action to perform on the component', }, properties: { type: 'object', - description: 'Properties to set on the component (for add/modify actions)', - additionalProperties: true - } + description: + 'Properties to set on the component (for add/modify actions)', + additionalProperties: true, + }, }, required: ['componentType', 'action'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('scene.modifyComponent', args); + const result = await unityClient.sendRequest( + 'scene.modifyComponent', + args + ); return result; - } + }, }, { name: 'scene.query', - description: 'Query the current scene hierarchy and get information about GameObjects and their components.', + description: + 'Query the current scene hierarchy and get information about GameObjects and their components.', inputSchema: { type: 'object', properties: { filter: { type: 'string', - description: 'Filter GameObjects by name pattern' + description: 'Filter GameObjects by name pattern', }, includeInactive: { type: 'boolean', description: 'Whether to include inactive GameObjects', - default: false + default: false, }, maxDepth: { type: 'number', description: 'Maximum hierarchy depth to query', - default: -1 - } + default: -1, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('scene.query', args); return result; - } + }, }, { @@ -143,20 +233,23 @@ export function createSceneTools(unityClient: UnityClient): Tool[] { objectNames: { type: 'array', items: { type: 'string' }, - description: 'Names of GameObjects to select' + description: 'Names of GameObjects to select', }, instanceIds: { type: 'array', items: { type: 'number' }, - description: 'Instance IDs of GameObjects to select' - } + description: 'Instance IDs of GameObjects to select', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('scene.selectObjects', args); + const result = await unityClient.sendRequest( + 'scene.selectObjects', + args + ); return result; - } + }, }, { @@ -167,19 +260,22 @@ export function createSceneTools(unityClient: UnityClient): Tool[] { properties: { gameObjectName: { type: 'string', - description: 'Name of the GameObject to delete' + description: 'Name of the GameObject to delete', }, gameObjectId: { type: 'number', - description: 'Instance ID of the GameObject to delete' - } + description: 'Instance ID of the GameObject to delete', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('scene.deleteGameObject', args); + const result = await unityClient.sendRequest( + 'scene.deleteGameObject', + args + ); return result; - } + }, }, { @@ -190,51 +286,54 @@ export function createSceneTools(unityClient: UnityClient): Tool[] { properties: { gameObjectName: { type: 'string', - description: 'Name of the GameObject to move' + description: 'Name of the GameObject to move', }, gameObjectId: { type: 'number', - description: 'Instance ID of the GameObject to move' + description: 'Instance ID of the GameObject to move', }, position: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, - z: { type: 'number' } + z: { type: 'number' }, }, - description: 'New world position' + description: 'New world position', }, rotation: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, - z: { type: 'number' } + z: { type: 'number' }, }, - description: 'New Euler rotation' + description: 'New Euler rotation', }, scale: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, - z: { type: 'number' } + z: { type: 'number' }, }, - description: 'New scale' + description: 'New scale', }, relative: { type: 'boolean', description: 'Whether the values are relative to current transform', - default: false - } + default: false, + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { - const result = await unityClient.sendRequest('scene.moveGameObject', args); + const result = await unityClient.sendRequest( + 'scene.moveGameObject', + args + ); return result; - } + }, }, { @@ -245,15 +344,15 @@ export function createSceneTools(unityClient: UnityClient): Tool[] { properties: { path: { type: 'string', - description: 'Path to save the scene (relative to Assets folder)' - } + description: 'Path to save the scene (relative to Assets folder)', + }, }, - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('scene.save', args); return result; - } + }, }, { @@ -264,21 +363,21 @@ export function createSceneTools(unityClient: UnityClient): Tool[] { properties: { scenePath: { type: 'string', - description: 'Path to the scene file' + description: 'Path to the scene file', }, additive: { type: 'boolean', description: 'Whether to load additively or replace current scene', - default: false - } + default: false, + }, }, required: ['scenePath'], - additionalProperties: false + additionalProperties: false, }, async execute(args: Record) { const result = await unityClient.sendRequest('scene.load', args); return result; - } - } + }, + }, ]; -} \ No newline at end of file +} diff --git a/Server/src/types/index.ts b/Server/src/types/index.ts index bd0e31c..693552f 100644 --- a/Server/src/types/index.ts +++ b/Server/src/types/index.ts @@ -121,4 +121,4 @@ export interface ServerConfig { logLevel: LogLevel; retryAttempts: number; retryDelay: number; -} \ No newline at end of file +} diff --git a/Server/src/utils/environment.ts b/Server/src/utils/environment.ts index fba8ef6..a5d5e79 100644 --- a/Server/src/utils/environment.ts +++ b/Server/src/utils/environment.ts @@ -23,10 +23,10 @@ export function validateEnvironment(): void { export function getConfig(): ServerConfig { return { - unityPort: parseInt(process.env.UNITY_PORT || '8090', 10), - requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '10', 10) * 1000, - logLevel: (process.env.LOG_LEVEL as ServerConfig['logLevel']) || 'info', - retryAttempts: parseInt(process.env.RETRY_ATTEMPTS || '3', 10), - retryDelay: parseInt(process.env.RETRY_DELAY || '1000', 10), + unityPort: parseInt(process.env.UNITY_PORT ?? '8090', 10), + requestTimeout: parseInt(process.env.REQUEST_TIMEOUT ?? '10', 10) * 1000, + logLevel: (process.env.LOG_LEVEL as ServerConfig['logLevel']) ?? 'info', + retryAttempts: parseInt(process.env.RETRY_ATTEMPTS ?? '3', 10), + retryDelay: parseInt(process.env.RETRY_DELAY ?? '1000', 10), }; -} \ No newline at end of file +} diff --git a/Server/src/utils/logger.ts b/Server/src/utils/logger.ts index e2f79b3..00a6c29 100644 --- a/Server/src/utils/logger.ts +++ b/Server/src/utils/logger.ts @@ -2,20 +2,21 @@ import winston from 'winston'; import type { LogLevel } from '../types/index.js'; // Get log level from environment or default to 'info' -const logLevel: LogLevel = (process.env.LOG_LEVEL as LogLevel) || 'info'; +const logLevel: LogLevel = (process.env.LOG_LEVEL as LogLevel) ?? 'info'; // Configure winston logger export const logger = winston.createLogger({ level: logLevel, format: winston.format.combine( winston.format.timestamp({ - format: 'YYYY-MM-DD HH:mm:ss' + format: 'YYYY-MM-DD HH:mm:ss', }), winston.format.errors({ stack: true }), winston.format.colorize({ all: true }), - winston.format.printf(({ timestamp, level, message, stack }) => { - const msg = `${timestamp} [${level}]: ${message}`; - return stack ? `${msg}\n${stack}` : msg; + winston.format.printf(info => { + const { timestamp, level, message, stack } = info; + const msg = `${String(timestamp)} [${String(level)}]: ${String(message)}`; + return stack ? `${msg}\n${String(stack)}` : msg; }) ), transports: [ @@ -24,17 +25,25 @@ export const logger = winston.createLogger({ handleRejections: true, // Send ALL logs to stderr so that stdout remains clean for stdio-based MCP JSON-RPC messages. // This prevents log lines from breaking the MCP transport when the server is spawned by clients like Grok. - stderrLevels: ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'] - }) + stderrLevels: [ + 'error', + 'warn', + 'info', + 'http', + 'verbose', + 'debug', + 'silly', + ], + }), ], - exitOnError: false + exitOnError: false, }); // Create a stream for Morgan or other HTTP loggers export const loggerStream = { write: (message: string): void => { logger.info(message.trim()); - } + }, }; -export default logger; \ No newline at end of file +export default logger; diff --git a/Unity/Editor/McpEditorHelpers.cs b/Unity/Editor/McpEditorHelpers.cs new file mode 100644 index 0000000..955dc88 --- /dev/null +++ b/Unity/Editor/McpEditorHelpers.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEditor; + +namespace UnityMCP.Editor +{ + internal static class McpEditorHelpers + { + public static string GetAction(object parameters, string defaultAction) + { + if (parameters == null) return defaultAction; + + try + { + if (parameters is JObject jObj && jObj["action"] != null) + return jObj["action"].ToString().Trim().ToLowerInvariant(); + + if (parameters is Dictionary dict && dict.TryGetValue("action", out var act)) + return act?.ToString().Trim().ToLowerInvariant() ?? defaultAction; + + var json = JsonConvert.SerializeObject(parameters); + var parsed = JsonConvert.DeserializeObject>(json); + if (parsed != null && parsed.TryGetValue("action", out var actionVal) && actionVal != null) + return actionVal.ToString().Trim().ToLowerInvariant(); + } + catch { /* fall through */ } + + return defaultAction; + } + + public static GameObject FindGameObject(string name, int instanceId = 0) + { + if (instanceId != 0) + { +#pragma warning disable CS0618 + var byId = EditorUtility.InstanceIDToObject(instanceId) as GameObject; +#pragma warning restore CS0618 + if (byId != null) return byId; + } + + if (!string.IsNullOrEmpty(name)) + { + var go = GameObject.Find(name); + if (go != null) return go; + + foreach (var obj in Resources.FindObjectsOfTypeAll()) + { + if (obj.name == name && obj.scene.isLoaded) + return obj; + } + } + + return null; + } + + public static string NormalizeAssetPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + var normalized = path.Replace("\\", "/").Trim(); + if (!normalized.StartsWith("Assets", StringComparison.OrdinalIgnoreCase)) + { + if (!normalized.StartsWith("/")) + normalized = "Assets/" + normalized.TrimStart('/'); + } + return normalized.StartsWith("Assets", StringComparison.OrdinalIgnoreCase) ? normalized : null; + } + + public static string EnsureAssetFolder(string folderPath) + { + var safeFolder = NormalizeAssetPath(folderPath) ?? "Assets"; + if (AssetDatabase.IsValidFolder(safeFolder)) return safeFolder; + + var parent = "Assets"; + var relative = safeFolder.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) + ? safeFolder.Substring("Assets/".Length) + : safeFolder; + + foreach (var part in relative.Split('/')) + { + if (string.IsNullOrWhiteSpace(part)) continue; + var next = $"{parent}/{part}"; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(parent, part); + parent = next; + } + + return safeFolder; + } + + public static Vector3? ExtractVector3(JToken token) + { + if (token == null) return null; + if (token is JArray arr && arr.Count >= 3) + return new Vector3(arr[0].Value(), arr[1].Value(), arr[2].Value()); + if (token is JObject obj) + return new Vector3( + obj.Value("x") ?? 0f, + obj.Value("y") ?? 0f, + obj.Value("z") ?? 0f); + return null; + } + + public static Color ExtractColor(JToken token, Color defaultColor) + { + if (token is JObject obj) + { + return new Color( + obj.Value("r") ?? defaultColor.r, + obj.Value("g") ?? defaultColor.g, + obj.Value("b") ?? defaultColor.b, + obj.Value("a") ?? defaultColor.a); + } + return defaultColor; + } + + public static Type FindComponentType(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) return null; + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var type = assembly.GetType(typeName, false, true); + if (type != null && typeof(Component).IsAssignableFrom(type)) + return type; + + foreach (var t in assembly.GetTypes()) + { + if (t.Name.Equals(typeName, StringComparison.OrdinalIgnoreCase) && + typeof(Component).IsAssignableFrom(t)) + return t; + } + } + catch { /* skip dynamic assemblies */ } + } + + return null; + } + + public sealed class ApplyPropertiesResult + { + public bool Success; + public List Applied = new List(); + public List Errors = new List(); + } + + public static ApplyPropertiesResult ApplySerializedProperties(UnityEngine.Object target, JObject properties) + { + var result = new ApplyPropertiesResult { Success = true }; + if (target == null || properties == null || !properties.HasValues) + return result; + + var serializedObject = new SerializedObject(target); + Undo.RecordObject(target, "Modify Component Properties"); + + foreach (var prop in properties.Properties()) + { + var property = FindSerializedProperty(serializedObject, prop.Name); + if (property == null) + { + result.Success = false; + result.Errors.Add($"Property '{prop.Name}' not found on {target.GetType().Name}"); + continue; + } + + try + { + if (!TrySetSerializedProperty(property, prop.Value)) + { + result.Success = false; + result.Errors.Add($"Failed to set '{prop.Name}' (unsupported type {property.propertyType})"); + continue; + } + + result.Applied.Add(prop.Name); + } + catch (Exception e) + { + result.Success = false; + result.Errors.Add($"Failed to set '{prop.Name}': {e.Message}"); + } + } + + serializedObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(target); + return result; + } + + public static PrimitiveType? ParsePrimitiveType(string primitiveName) + { + if (string.IsNullOrWhiteSpace(primitiveName)) return null; + + switch (primitiveName.Trim().ToLowerInvariant()) + { + case "cube": return PrimitiveType.Cube; + case "sphere": return PrimitiveType.Sphere; + case "capsule": return PrimitiveType.Capsule; + case "cylinder": return PrimitiveType.Cylinder; + case "plane": return PrimitiveType.Plane; + case "quad": return PrimitiveType.Quad; + default: return null; + } + } + + private static SerializedProperty FindSerializedProperty(SerializedObject serializedObject, string name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + + var direct = serializedObject.FindProperty(name); + if (direct != null) return direct; + + var mName = name.StartsWith("m_", StringComparison.Ordinal) + ? name + : "m_" + char.ToUpperInvariant(name[0]) + name.Substring(1); + + direct = serializedObject.FindProperty(mName); + if (direct != null) return direct; + + var iterator = serializedObject.GetIterator(); + while (iterator.NextVisible(true)) + { + if (string.Equals(iterator.name, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(iterator.displayName, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(iterator.name, mName, StringComparison.OrdinalIgnoreCase)) + { + return serializedObject.FindProperty(iterator.propertyPath); + } + } + + return null; + } + + private static bool TrySetSerializedProperty(SerializedProperty property, JToken value) + { + switch (property.propertyType) + { + case SerializedPropertyType.Integer: + property.intValue = value.Type == JTokenType.Boolean + ? (value.Value() ? 1 : 0) + : value.Value(); + return true; + + case SerializedPropertyType.Float: + property.floatValue = value.Value(); + return true; + + case SerializedPropertyType.Boolean: + property.boolValue = value.Value(); + return true; + + case SerializedPropertyType.String: + property.stringValue = value.Type == JTokenType.Null ? "" : value.ToString(); + return true; + + case SerializedPropertyType.Enum: + { + if (value.Type == JTokenType.Integer) + { + property.enumValueIndex = value.Value(); + return true; + } + + var enumName = value.ToString(); + var names = property.enumDisplayNames; + for (int i = 0; i < names.Length; i++) + { + if (string.Equals(names[i], enumName, StringComparison.OrdinalIgnoreCase)) + { + property.enumValueIndex = i; + return true; + } + } + + return false; + } + + case SerializedPropertyType.Vector2: + { + var v = ParseVector2(value); + if (!v.HasValue) return false; + property.vector2Value = v.Value; + return true; + } + + case SerializedPropertyType.Vector3: + { + var v = ExtractVector3(value); + if (!v.HasValue) return false; + property.vector3Value = v.Value; + return true; + } + + case SerializedPropertyType.Vector4: + { + var v = ParseVector4(value); + if (!v.HasValue) return false; + property.vector4Value = v.Value; + return true; + } + + case SerializedPropertyType.Color: + property.colorValue = ExtractColor(value, property.colorValue); + return true; + + case SerializedPropertyType.ObjectReference: + property.objectReferenceValue = ResolveObjectReference(value, property); + return property.objectReferenceValue != null || value.Type == JTokenType.Null; + + case SerializedPropertyType.ArraySize: + return false; + + default: + if (property.isArray && value is JArray array) + { + property.arraySize = array.Count; + for (int i = 0; i < array.Count; i++) + { + var element = property.GetArrayElementAtIndex(i); + if (!TrySetSerializedProperty(element, array[i])) + return false; + } + return true; + } + + return false; + } + } + + private static Vector2? ParseVector2(JToken token) + { + if (token is JArray arr && arr.Count >= 2) + return new Vector2(arr[0].Value(), arr[1].Value()); + if (token is JObject obj) + return new Vector2( + obj.Value("x") ?? 0f, + obj.Value("y") ?? 0f); + return null; + } + + private static Vector4? ParseVector4(JToken token) + { + if (token is JArray arr && arr.Count >= 4) + return new Vector4(arr[0].Value(), arr[1].Value(), arr[2].Value(), arr[3].Value()); + if (token is JObject obj) + return new Vector4( + obj.Value("x") ?? 0f, + obj.Value("y") ?? 0f, + obj.Value("z") ?? 0f, + obj.Value("w") ?? 0f); + return null; + } + + private static UnityEngine.Object ResolveObjectReference(JToken value, SerializedProperty property) + { + if (value == null || value.Type == JTokenType.Null) + return null; + + if (value.Type == JTokenType.Integer) + { +#pragma warning disable CS0618 + return EditorUtility.InstanceIDToObject(value.Value()); +#pragma warning restore CS0618 + } + + if (value.Type == JTokenType.String) + { + var path = value.ToString(); + if (path.StartsWith("Assets", StringComparison.OrdinalIgnoreCase)) + return AssetDatabase.LoadAssetAtPath(NormalizeAssetPath(path)); + + var go = FindGameObject(path); + if (go != null) + { + var compType = ResolvePropertyReferencedType(property); + if (compType != null && typeof(Component).IsAssignableFrom(compType)) + return go.GetComponent(compType) ?? go.GetComponents(compType).FirstOrDefault(); + return go; + } + } + + if (value is JObject obj) + { + var instanceId = obj.Value("instanceId") ?? obj.Value("gameObjectId") ?? 0; + var name = obj.Value("gameObjectName") ?? obj.Value("name"); + var assetPath = obj.Value("path"); + + if (instanceId != 0) + { +#pragma warning disable CS0618 + var byId = EditorUtility.InstanceIDToObject(instanceId); +#pragma warning restore CS0618 + if (byId != null) return byId; + } + + if (!string.IsNullOrWhiteSpace(assetPath)) + { + var normalized = NormalizeAssetPath(assetPath); + if (normalized != null) + return AssetDatabase.LoadAssetAtPath(normalized); + } + + if (!string.IsNullOrWhiteSpace(name)) + { + var gameObject = FindGameObject(name); + if (gameObject == null) return null; + + var componentTypeName = obj.Value("componentType"); + if (!string.IsNullOrWhiteSpace(componentTypeName)) + { + var type = FindComponentType(componentTypeName); + return type != null ? gameObject.GetComponent(type) : null; + } + + var compType = ResolvePropertyReferencedType(property); + if (compType != null && typeof(Component).IsAssignableFrom(compType)) + return gameObject.GetComponent(compType) ?? gameObject.GetComponents(compType).FirstOrDefault(); + + return gameObject; + } + } + + return null; + } + + private static Type ResolvePropertyReferencedType(SerializedProperty property) + { + if (property == null || string.IsNullOrWhiteSpace(property.type)) + return null; + + if (string.Equals(property.type, "GameObject", StringComparison.OrdinalIgnoreCase)) + return typeof(GameObject); + + var componentType = FindComponentType(property.type); + if (componentType != null) + return componentType; + + return Type.GetType($"UnityEngine.{property.type}, UnityEngine.CoreModule") + ?? Type.GetType($"UnityEngine.{property.type}, UnityEngine"); + } + } +} \ No newline at end of file diff --git a/Unity/Editor/McpEditorHelpers.cs.meta b/Unity/Editor/McpEditorHelpers.cs.meta new file mode 100644 index 0000000..b8d25d1 --- /dev/null +++ b/Unity/Editor/McpEditorHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f3a9c2e1b4d5680a9e3f2c1d8b7a604 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Unity/Editor/McpUnityServer.cs b/Unity/Editor/McpUnityServer.cs index df9848f..3880804 100644 --- a/Unity/Editor/McpUnityServer.cs +++ b/Unity/Editor/McpUnityServer.cs @@ -4,6 +4,7 @@ using System.Net.NetworkInformation; using System.Text; using System.Threading; +using System.Collections.Concurrent; using UnityEngine; using UnityEditor; using Newtonsoft.Json; @@ -22,7 +23,11 @@ public class McpUnityServer : EditorWindow private const string PREF_SERVER_PORT = "UnityMCP_ServerPort"; private const string PREF_AUTO_START = "UnityMCP_AutoStart"; private const string PREF_REQUEST_TIMEOUT = "UnityMCP_RequestTimeout"; - + // SessionState survives domain reloads (script recompile / Play Mode with Reload + // Domain enabled) but resets when Unity restarts. We use it to restart the listener + // after a reload without cold-starting on every fresh Unity launch. + private const string SESSION_WAS_RUNNING = "UnityMCP_WasRunning"; + private static McpUnityServer instance; private static HttpListener httpListener; private static bool isServerRunning = false; @@ -30,21 +35,55 @@ public class McpUnityServer : EditorWindow private static int requestTimeout = 10; private static bool autoStart = false; private static Thread listenerThread; + private static readonly ConcurrentQueue mainThreadActions = new ConcurrentQueue(); + private static int mainThreadId; + private static bool mainThreadPumpScheduled; private Vector2 scrollPosition; private string logText = ""; private readonly List logs = new List(); - private readonly Dictionary tools = new Dictionary(); - + private static readonly Dictionary tools = new Dictionary(); + private static bool toolsInitialized = false; + static McpUnityServer() { - LoadPreferences(); + mainThreadId = Thread.CurrentThread.ManagedThreadId; EditorApplication.playModeStateChanged += OnPlayModeStateChanged; - - if (autoStart) + + // Single persistent main-thread pump. Tool execution is dispatched here + // (via RunOnMainThread) instead of EditorApplication.delayCall, which is a + // non-thread-safe static delegate and drops callbacks when subscribed from + // the background listener thread under concurrency. + EditorApplication.update += PumpMainThreadActions; + + // A domain reload wipes these statics and kills the listener thread. Persist + // the running state just before the reload so we can restart afterwards. + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + + // IMPORTANT: Never call LoadPreferences() (or any EditorPrefs.*) directly + // from the static constructor of an EditorWindow/ScriptableObject-derived type. + // Unity forbids it and throws "GetInt is not allowed to be called from a + // ScriptableObject constructor". We defer it. + EditorApplication.delayCall += () => { - EditorApplication.delayCall += () => StartServer(); - } + LoadPreferences(); + + // Restart if the server was running before a domain reload, or if the + // user opted into auto-start for fresh Unity sessions. + if (SessionState.GetBool(SESSION_WAS_RUNNING, false) || autoStart) + { + StartServer(); + } + }; + } + + private static void OnBeforeAssemblyReload() + { + // Capture running state for the post-reload restart, then release the HTTP + // port directly so the new domain can re-bind it. We bypass StopServer() here + // because that clears the persisted "was running" flag (user-stop semantics). + SessionState.SetBool(SESSION_WAS_RUNNING, isServerRunning); + ShutdownListener(); } [MenuItem(MENU_PATH, false, 1)] @@ -59,7 +98,7 @@ private void OnEnable() { instance = this; LoadPreferences(); - InitializeTools(); + EnsureToolsInitialized(); RefreshUI(); } @@ -215,13 +254,21 @@ public static void StartServer() try { + // Ensure the tool registry is populated before any request can arrive. + // The registry is static and independent of the EditorWindow, so the + // server functions even when the window is closed. + EnsureToolsInitialized(); + httpListener = new HttpListener(); httpListener.Prefixes.Add($"http://localhost:{serverPort}/"); httpListener.Start(); - + isServerRunning = true; + SessionState.SetBool(SESSION_WAS_RUNNING, true); LogMessage($"MCP Server started on port {serverPort}"); - + + EnsureMainThreadPump(); + // Start listening thread listenerThread = new Thread(HandleRequests); listenerThread.Start(); @@ -248,11 +295,10 @@ public static void StopServer() try { - isServerRunning = false; - httpListener?.Stop(); - listenerThread?.Join(1000); - httpListener = null; - + ShutdownListener(); + // User-initiated stop: do not auto-restart after a later domain reload. + SessionState.SetBool(SESSION_WAS_RUNNING, false); + LogMessage("MCP Server stopped"); RefreshUI(); } @@ -262,6 +308,16 @@ public static void StopServer() } } + private static void ShutdownListener() + { + isServerRunning = false; + mainThreadPumpScheduled = false; + try { httpListener?.Stop(); } + catch (Exception e) { LogError($"Error stopping HTTP listener: {e.Message}"); } + listenerThread?.Join(1000); + httpListener = null; + } + private static void HandleRequests() { while (isServerRunning && httpListener != null) @@ -269,7 +325,10 @@ private static void HandleRequests() try { var context = httpListener.GetContext(); - ProcessRequest(context); + // Service each request on a worker thread so the listener can immediately + // accept the next connection. ExecuteTool blocks its worker while waiting + // for the main-thread pump; that must not stall the accept loop. + ThreadPool.QueueUserWorkItem(_ => ProcessRequest(context)); } catch (HttpListenerException) { @@ -318,11 +377,17 @@ private static void ProcessRequest(HttpListenerContext context) } } - private void InitializeTools() + private static void EnsureToolsInitialized() { + if (toolsInitialized) + { + return; + } + tools.Clear(); - - // Register all available tools + + // Register all available tools. Static registry is independent of the + // EditorWindow so requests succeed even when the window is closed. RegisterTool(new ProjectAnalyzerTool()); RegisterTool(new SceneManipulationTool()); RegisterTool(new AssetManagerTool()); @@ -333,10 +398,11 @@ private void InitializeTools() RegisterTool(new ProjectMemoryTool()); RegisterTool(new LabTool()); + toolsInitialized = true; LogMessage($"Initialized {tools.Count} MCP tools"); } - private void RegisterTool(McpToolBase tool) + private static void RegisterTool(McpToolBase tool) { if (tool != null && !string.IsNullOrEmpty(tool.ToolName)) { @@ -344,23 +410,85 @@ private void RegisterTool(McpToolBase tool) } } + /// + /// Enqueues an action to run on the Unity main thread. Thread-safe; drained by + /// PumpMainThreadActions on EditorApplication.update. + /// + public static void RunOnMainThread(Action action) + { + if (action == null) return; + + if (Thread.CurrentThread.ManagedThreadId == mainThreadId) + { + try { action(); } + catch (Exception e) { LogError($"Main-thread action failed: {e.Message}"); } + return; + } + + mainThreadActions.Enqueue(action); + EnsureMainThreadPump(); + EditorApplication.QueuePlayerLoopUpdate(); + } + + private static void EnsureMainThreadPump() + { + if (mainThreadPumpScheduled) return; + mainThreadPumpScheduled = true; + EditorApplication.delayCall += MainThreadPumpTick; + } + + private static void MainThreadPumpTick() + { + PumpMainThreadActions(); + if (isServerRunning) + { + EditorApplication.delayCall += MainThreadPumpTick; + } + else + { + mainThreadPumpScheduled = false; + } + } + + private static void PumpMainThreadActions() + { + while (mainThreadActions.TryDequeue(out var action)) + { + try + { + action(); + } + catch (Exception e) + { + LogError($"Main-thread action failed: {e.Message}"); + } + } + } + + private class ToolExecutionState + { + public volatile bool IsComplete; + public McpResponse Result; + public Exception Error; + public volatile AsyncToolResult Async; + } + public static McpResponse ExecuteTool(string toolName, object parameters) { - if (!instance.tools.ContainsKey(toolName)) + EnsureToolsInitialized(); + + if (!tools.ContainsKey(toolName)) { return McpResponse.CreateError($"Tool '{toolName}' not found"); } try { - var tool = instance.tools[toolName]; - McpResponse result = null; - Exception resultException = null; - bool isComplete = false; - AsyncToolResult asyncOp = null; + var tool = tools[toolName]; + var state = new ToolExecutionState(); - // Execute tool on main thread - EditorApplication.delayCall += () => + // Dispatch tool execution to the main thread via the persistent pump. + RunOnMainThread(() => { try { @@ -368,33 +496,32 @@ public static McpResponse ExecuteTool(string toolName, object parameters) if (execResult is AsyncToolResult async) { - asyncOp = async; + state.Async = async; } else { - result = McpResponse.CreateSuccess(execResult); + state.Result = McpResponse.CreateSuccess(execResult); LogMessage($"Executed tool: {toolName}"); - isComplete = true; + state.IsComplete = true; } } catch (Exception e) { LogError($"Error executing tool '{toolName}': {e.Message}"); - resultException = e; - isComplete = true; + state.Error = e; + state.IsComplete = true; } - }; + }); - // Wait for completion (with timeout) + // Wait for completion (with timeout) on this worker thread. var startTime = DateTime.Now; var defaultTimeout = TimeSpan.FromSeconds(requestTimeout); - while (!isComplete && DateTime.Now - startTime < defaultTimeout) + while (!state.IsComplete && DateTime.Now - startTime < defaultTimeout) { - // Check if async operation was started + var asyncOp = state.Async; if (asyncOp != null) { - // Extend timeout to accommodate the async operation var asyncTimeout = TimeSpan.FromSeconds(asyncOp.TimeoutSeconds > 0 ? asyncOp.TimeoutSeconds : requestTimeout); @@ -407,35 +534,36 @@ public static McpResponse ExecuteTool(string toolName, object parameters) if (asyncOp.IsComplete) { - result = asyncOp.Error != null + state.Result = asyncOp.Error != null ? McpResponse.CreateError(asyncOp.Error) : McpResponse.CreateSuccess(asyncOp.Result); LogMessage($"Executed async tool: {toolName}"); } else { - result = McpResponse.CreateError( + state.Result = McpResponse.CreateError( $"Async tool '{toolName}' timed out after {asyncTimeout.TotalSeconds}s"); } - isComplete = true; + state.IsComplete = true; break; } + EditorApplication.QueuePlayerLoopUpdate(); Thread.Sleep(10); } - if (!isComplete) + if (!state.IsComplete) { return McpResponse.CreateError($"Tool '{toolName}' execution timed out after {requestTimeout} seconds"); } - if (resultException != null) + if (state.Error != null) { - return McpResponse.CreateError(resultException.Message); + return McpResponse.CreateError(state.Error.Message); } - return result; + return state.Result; } catch (Exception e) { @@ -451,24 +579,53 @@ private static McpResponse ProcessMcpRequest(McpRequest request) return McpResponse.CreateError("Invalid request format"); } - // Parse method to extract tool name - var methodParts = request.Method.Split('.'); + EnsureToolsInitialized(); + + var method = request.Method.Trim(); + + // Connection health check used by the Node MCP client on startup. + if (method.Equals("test", StringComparison.OrdinalIgnoreCase) || + method.Equals("test.ping", StringComparison.OrdinalIgnoreCase)) + { + return McpResponse.CreateSuccess(new + { + status = "ok", + tools = tools.Count, + port = serverPort + }); + } + + // Direct tool invocation without a dot (e.g. "lab", "project_memory"). + if (!method.Contains(".")) + { + if (tools.ContainsKey(method)) + { + return ExecuteTool(method, request.Params); + } + + return McpResponse.CreateError($"No tool found for method: {method}"); + } + + var methodParts = method.Split('.'); if (methodParts.Length < 2) { - return McpResponse.CreateError($"Invalid method format: {request.Method}"); + return McpResponse.CreateError($"Invalid method format: {method}"); } - string toolCategory = methodParts[0]; + string rawCategory = methodParts[0]; + string toolCategory = NormalizeCategory(rawCategory); string toolAction = methodParts[1]; string exactToolName = $"{toolCategory}_{toolAction}"; + string exactToolNameAlt = $"{rawCategory}_{toolAction}"; - if (instance == null) + // Try the un-normalized category match first (e.g., "assets_create"). + if (tools.ContainsKey(exactToolNameAlt)) { - return McpResponse.CreateError("MCP Server window is not open. Open it via Tools > Unity MCP > Server Window."); + return ExecuteTool(exactToolNameAlt, request.Params); } - // Try exact match first (e.g., "scene_capture") - if (instance.tools.ContainsKey(exactToolName)) + // Then the normalized exact match (e.g., "scene_capture"). + if (tools.ContainsKey(exactToolName)) { return ExecuteTool(exactToolName, request.Params); } @@ -476,12 +633,11 @@ private static McpResponse ProcessMcpRequest(McpRequest request) // Fall back to category-level tool and inject action into params. // This routes "playmode.enter" → tool "playmode" with action="enter", // and "scene.createGameObject" → tool "scene_manipulate" with action injected. - foreach (var kvp in instance.tools) + foreach (var kvp in tools) { if (kvp.Value.Category == toolCategory) { - // Inject the action into the params so the tool can dispatch - var paramsWithAction = InjectAction(request.Params, toolAction); + var paramsWithAction = InjectAction(request.Params, NormalizeAction(toolAction)); return ExecuteTool(kvp.Key, paramsWithAction); } } @@ -489,6 +645,66 @@ private static McpResponse ProcessMcpRequest(McpRequest request) return McpResponse.CreateError($"No tool found for method: {request.Method}"); } + private static string NormalizeCategory(string category) + { + switch (category) + { + case "assets": + case "packages": + return "asset"; + default: + return category; + } + } + + private static string NormalizeAction(string action) + { + if (string.IsNullOrEmpty(action)) + { + return action; + } + + switch (action) + { + case "createGameObject": return "create_object"; + case "createPrimitive": return "create_primitive"; + case "deleteGameObject": return "delete_object"; + case "moveGameObject": return "set_transform"; + case "createScript": return "create_script"; + case "analyze": return "analyze"; + case "attachScript": return "attach_script"; + case "managePrefabs": return "manage_prefabs"; + case "createMaterial": return "create_material"; + case "createTexture": return "create_texture"; + case "getInfo": return "get_info"; + case "runTests": return "run_tests"; + case "getReport": return "get_report"; + case "getConsoleLogs": return "get_console_logs"; + default: + return CamelCaseToSnakeCase(action); + } + } + + private static string CamelCaseToSnakeCase(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var builder = new StringBuilder(); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (char.IsUpper(c) && i > 0) + { + builder.Append('_'); + } + builder.Append(char.ToLowerInvariant(c)); + } + return builder.ToString(); + } + private static object InjectAction(object parameters, string action) { try diff --git a/Unity/Editor/Tools/AssetManagerTool.cs b/Unity/Editor/Tools/AssetManagerTool.cs index 68432ce..f4c6654 100644 --- a/Unity/Editor/Tools/AssetManagerTool.cs +++ b/Unity/Editor/Tools/AssetManagerTool.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Newtonsoft.Json.Linq; using UnityEngine; using UnityEditor; @@ -25,7 +26,27 @@ public class AssetParameters public string type = ""; public string assetPath = ""; public string destinationPath = ""; + public string targetPath = ""; + public string sourcePath = ""; + public string newName = ""; + public string name = ""; + public string path = ""; + public string savePath = ""; + public string guid = ""; + public string query = ""; + public string shader = "Standard"; + public string packageName = ""; + public string version = ""; + public string prefabPath = ""; + public string gameObjectName = ""; public bool includeSubfolders = true; + public bool includeMetadata = true; + public bool includeDependencies = false; + public bool recursive = false; + public int maxResults = 100; + public int width = 256; + public int height = 256; + public string format = "RGBA32"; } public override object Execute(object parameters) @@ -33,16 +54,32 @@ public override object Execute(object parameters) try { var args = GetParameters(parameters); - var action = (args.action ?? "list_assets").Trim().ToLowerInvariant(); + var action = McpEditorHelpers.GetAction(parameters, args.action ?? "list_assets"); + + if (action is "create" or "instantiate" or "update" && + (!string.IsNullOrWhiteSpace(args.prefabPath) || !string.IsNullOrWhiteSpace(args.gameObjectName))) + return ManagePrefabs(args, parameters); + + if (action is "move" or "copy" or "rename" or "delete" or "createfolder" or "create_folder") + return OrganizeAsset(args, parameters); return action switch { "list_assets" => ListAssets(args), - "find_assets" => FindAssets(args), - "create_folder" => CreateFolder(args), - "move_asset" => MoveAsset(args), - "delete_asset" => DeleteAsset(args), - _ => CreateErrorResponse($"Unknown action '{args.action}'. Supported actions: list_assets, find_assets, create_folder, move_asset, delete_asset") + "find_assets" or "search" => SearchAssets(args), + "create_folder" or "createfolder" => CreateFolder(args), + "move_asset" or "move" => MoveAsset(args), + "delete_asset" or "delete" => DeleteAsset(args), + "import" => ImportAsset(args), + "create_material" => CreateMaterial(args, parameters), + "manage_prefabs" => ManagePrefabs(args, parameters), + "organize" => OrganizeAsset(args, parameters), + "create_texture" => CreateTexture(args, parameters), + "get_info" => GetAssetInfo(args), + "manage_packages" or "manage" or "list" => ManagePackages(args, parameters), + "install" or "update" or "remove" => ManagePackages(args, parameters), + _ => CreateErrorResponse( + $"Unknown asset action '{action}'. Supported: import, create_material, manage_prefabs, organize, search, create_texture, get_info, manage_packages, list_assets, move_asset, delete_asset") }; } catch (Exception e) @@ -52,170 +89,417 @@ public override object Execute(object parameters) } } - private object ListAssets(AssetParameters args) + private object ImportAsset(AssetParameters args) { - var folder = NormalizeFolder(args.folder); - if (folder == null) + if (string.IsNullOrWhiteSpace(args.sourcePath)) + return CreateErrorResponse("sourcePath is required."); + + if (!File.Exists(args.sourcePath)) + return CreateErrorResponse($"Source file not found: {args.sourcePath}"); + + var targetFolder = McpEditorHelpers.EnsureAssetFolder( + string.IsNullOrWhiteSpace(args.targetPath) ? "Assets/Imported" : args.targetPath); + + var fileName = Path.GetFileName(args.sourcePath); + var destPath = $"{targetFolder}/{fileName}"; + + if (File.Exists(destPath)) + destPath = AssetDatabase.GenerateUniqueAssetPath(destPath); + + File.Copy(args.sourcePath, destPath, true); + AssetDatabase.ImportAsset(destPath); + + return CreateSuccessResponse(new { - return CreateErrorResponse("Folder must be inside the Assets folder."); - } + source = args.sourcePath, + importedPath = destPath, + type = AssetDatabase.GetMainAssetTypeAtPath(destPath)?.Name + }, "Asset imported"); + } - if (!AssetDatabase.IsValidFolder(folder)) + private object CreateMaterial(AssetParameters args, object rawParams) + { + if (string.IsNullOrWhiteSpace(args.name)) + return CreateErrorResponse("name is required."); + + var folder = McpEditorHelpers.EnsureAssetFolder( + string.IsNullOrWhiteSpace(args.path) ? "Assets/Materials" : args.path); + + var shader = Shader.Find(args.shader ?? "Standard") ?? Shader.Find("Universal Render Pipeline/Lit"); + if (shader == null) + return CreateErrorResponse($"Shader '{args.shader}' not found."); + + var material = new Material(shader) { name = args.name }; + + if (rawParams is JObject jObj && jObj["properties"] is JObject props) { - return CreateErrorResponse($"Folder does not exist: {folder}"); + if (props["color"] != null) + material.color = McpEditorHelpers.ExtractColor(props["color"], Color.white); + if (props["metallic"] != null && material.HasProperty("_Metallic")) + material.SetFloat("_Metallic", props["metallic"].Value()); + if (props["smoothness"] != null && material.HasProperty("_Glossiness")) + material.SetFloat("_Glossiness", props["smoothness"].Value()); } - var assetPaths = AssetDatabase.GetAllAssetPaths() - .Where(path => path.StartsWith(folder, StringComparison.OrdinalIgnoreCase)) - .Where(path => args.includeSubfolders || Path.GetDirectoryName(path) == folder) - .ToArray(); + var assetPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{args.name}.mat"); + AssetDatabase.CreateAsset(material, assetPath); - var assets = new List(); - foreach (var path in assetPaths) + return CreateSuccessResponse(new { path = assetPath, name = args.name, shader = shader.name }, "Material created"); + } + + private object ManagePrefabs(AssetParameters args, object rawParams) + { + var subAction = "create"; + if (rawParams is JObject jObj) + subAction = (jObj.Value("action") ?? "create").Trim().ToLowerInvariant(); + + switch (subAction) { - var assetType = AssetDatabase.GetMainAssetTypeAtPath(path); - assets.Add(new + case "create": { - path, - type = assetType?.Name ?? "Unknown" - }); + if (string.IsNullOrWhiteSpace(args.gameObjectName)) + return CreateErrorResponse("gameObjectName is required to create a prefab."); + + var go = McpEditorHelpers.FindGameObject(args.gameObjectName); + if (go == null) + return CreateErrorResponse($"GameObject '{args.gameObjectName}' not found."); + + var prefabPath = McpEditorHelpers.NormalizeAssetPath(args.prefabPath ?? $"Assets/Prefabs/{go.name}.prefab"); + if (prefabPath == null) + return CreateErrorResponse("prefabPath must be inside Assets."); + + McpEditorHelpers.EnsureAssetFolder(Path.GetDirectoryName(prefabPath)?.Replace("\\", "/")); + prefabPath = AssetDatabase.GenerateUniqueAssetPath(prefabPath); + + var prefab = PrefabUtility.SaveAsPrefabAsset(go, prefabPath); + return CreateSuccessResponse(new { path = prefabPath, name = prefab.name }, "Prefab created"); + } + case "instantiate": + { + var prefabPath = McpEditorHelpers.NormalizeAssetPath(args.prefabPath); + if (prefabPath == null) + return CreateErrorResponse("prefabPath is required."); + + var prefab = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefab == null) + return CreateErrorResponse($"Prefab not found at {prefabPath}"); + + var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); + if (rawParams is JObject j && j["instantiatePosition"] is JObject pos) + { + var v = McpEditorHelpers.ExtractVector3(pos); + if (v.HasValue) instance.transform.position = v.Value; + } + + return CreateSuccessResponse(new + { + name = instance.name, + instanceId = UnityCompat.GetObjectId(instance) + }, "Prefab instantiated"); + } + default: + return CreateErrorResponse($"Prefab action '{subAction}' is not yet implemented. Supported: create, instantiate."); } + } - return CreateSuccessResponse(new + private object OrganizeAsset(AssetParameters args, object rawParams) + { + var subAction = args.action; + if (rawParams is JObject jObj && jObj["action"] != null) { - folder, - count = assets.Count, - assets - }, "Assets listed"); + var nodeAction = jObj["action"].ToString().Trim().ToLowerInvariant(); + if (nodeAction is "move" or "copy" or "rename" or "delete" or "createfolder" or "create_folder") + subAction = nodeAction; + } + + subAction = (subAction ?? "move").Trim().ToLowerInvariant(); + + switch (subAction) + { + case "createfolder": + case "create_folder": + args.folder = args.targetPath ?? args.sourcePath ?? args.folder; + return CreateFolder(args); + + case "move": + case "move_asset": + args.assetPath = args.sourcePath ?? args.assetPath; + args.destinationPath = args.targetPath ?? args.destinationPath; + return MoveAsset(args); + + case "copy": + { + var src = McpEditorHelpers.NormalizeAssetPath(args.sourcePath ?? args.assetPath); + var dst = McpEditorHelpers.NormalizeAssetPath(args.targetPath ?? args.destinationPath); + if (src == null || dst == null) + return CreateErrorResponse("sourcePath and targetPath are required."); + + if (AssetDatabase.IsValidFolder(src)) + return CreateErrorResponse("Copying folders is not supported. Copy individual assets."); + + if (!UnityCompat.TryCopyAsset(src, dst, out var copyError)) + return CreateErrorResponse(copyError ?? "Failed to copy asset"); + return CreateSuccessResponse(new { from = src, to = dst }, "Asset copied"); + } + + case "rename": + { + var src = McpEditorHelpers.NormalizeAssetPath(args.sourcePath ?? args.assetPath); + if (src == null) + return CreateErrorResponse("sourcePath is required."); + var newName = args.newName ?? args.name; + if (string.IsNullOrWhiteSpace(newName)) + return CreateErrorResponse("newName is required."); + + var dir = Path.GetDirectoryName(src)?.Replace("\\", "/"); + var dst = $"{dir}/{newName}{Path.GetExtension(src)}"; + var error = AssetDatabase.MoveAsset(src, dst); + if (!string.IsNullOrEmpty(error)) + return CreateErrorResponse(error); + return CreateSuccessResponse(new { from = src, to = dst }, "Asset renamed"); + } + + case "delete": + case "delete_asset": + args.assetPath = args.sourcePath ?? args.assetPath; + return DeleteAsset(args); + + default: + return CreateErrorResponse($"Unknown organize action '{subAction}'."); + } } - private object FindAssets(AssetParameters args) + private object SearchAssets(AssetParameters args) { - var filter = string.IsNullOrWhiteSpace(args.filter) ? "t:Object" : args.filter; - var searchFolders = NormalizeFolder(args.folder) is string folder ? new[] { folder } : null; + var searchFilter = "t:Object"; + if (!string.IsNullOrWhiteSpace(args.type)) + searchFilter = $"t:{args.type}"; + else if (!string.IsNullOrWhiteSpace(args.filter)) + searchFilter = args.filter; + + if (!string.IsNullOrWhiteSpace(args.query)) + searchFilter = string.IsNullOrWhiteSpace(args.type) ? args.query : $"{args.query} t:{args.type}"; + + var folders = McpEditorHelpers.NormalizeAssetPath(args.path ?? args.folder) is string f + ? new[] { f } + : new[] { "Assets" }; - var guids = AssetDatabase.FindAssets(filter, searchFolders); + var guids = AssetDatabase.FindAssets(searchFilter, folders); var assets = new List(); + var limit = Mathf.Clamp(args.maxResults, 1, 500); - foreach (var guid in guids) + foreach (var guid in guids.Take(limit)) { - var path = AssetDatabase.GUIDToAssetPath(guid); - var assetType = AssetDatabase.GetMainAssetTypeAtPath(path); + var assetPath = AssetDatabase.GUIDToAssetPath(guid); assets.Add(new { - path, - type = assetType?.Name ?? "Unknown" + path = assetPath, + guid, + type = AssetDatabase.GetMainAssetTypeAtPath(assetPath)?.Name ?? "Unknown", + name = Path.GetFileNameWithoutExtension(assetPath) }); } return CreateSuccessResponse(new { - filter, + query = args.query ?? searchFilter, count = assets.Count, + totalMatches = guids.Length, assets - }, "Assets found"); + }, $"Found {assets.Count} asset(s)"); } - private object CreateFolder(AssetParameters args) + private object CreateTexture(AssetParameters args, object rawParams) { - var folder = NormalizeFolder(args.folder); - if (folder == null || string.Equals(folder, "Assets", StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(args.name)) + return CreateErrorResponse("name is required."); + + args.width = Mathf.Clamp(args.width, 1, 4096); + args.height = Mathf.Clamp(args.height, 1, 4096); + + var color = Color.white; + if (rawParams is JObject jObj && jObj["color"] != null) + color = McpEditorHelpers.ExtractColor(jObj["color"], Color.white); + + var tex = new Texture2D(args.width, args.height, TextureFormat.RGBA32, false); + var pixels = new Color[args.width * args.height]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = color; + tex.SetPixels(pixels); + tex.Apply(); + + var folder = McpEditorHelpers.EnsureAssetFolder( + string.IsNullOrWhiteSpace(args.savePath) ? "Assets/Textures" : args.savePath); + var assetPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{args.name}.png"); + File.WriteAllBytes(assetPath, tex.EncodeToPNG()); + AssetDatabase.ImportAsset(assetPath); + UnityEngine.Object.DestroyImmediate(tex); + + return CreateSuccessResponse(new { - return CreateErrorResponse("Provide a folder path inside Assets to create."); - } + path = assetPath, + width = args.width, + height = args.height + }, "Texture created"); + } - if (AssetDatabase.IsValidFolder(folder)) + private object GetAssetInfo(AssetParameters args) + { + string assetPath = null; + if (!string.IsNullOrWhiteSpace(args.guid)) + assetPath = AssetDatabase.GUIDToAssetPath(args.guid); + else + assetPath = McpEditorHelpers.NormalizeAssetPath(args.assetPath ?? args.path); + + if (string.IsNullOrEmpty(assetPath)) + return CreateErrorResponse("assetPath or guid is required."); + + var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath); + var importer = AssetImporter.GetAtPath(assetPath); + long fileSize = 0; + if (File.Exists(assetPath)) + fileSize = new FileInfo(assetPath).Length; + + var info = new Dictionary { - return CreateErrorResponse($"Folder already exists: {folder}"); + ["path"] = assetPath, + ["guid"] = AssetDatabase.AssetPathToGUID(assetPath), + ["name"] = Path.GetFileNameWithoutExtension(assetPath), + ["type"] = mainType?.Name ?? "Unknown", + ["sizeBytes"] = fileSize + }; + + if (args.includeMetadata && importer != null) + info["importerType"] = importer.GetType().Name; + + if (args.includeDependencies) + { + var deps = AssetDatabase.GetDependencies(assetPath, args.recursive); + info["dependencies"] = deps; + info["dependencyCount"] = deps.Length; } - var parent = Path.GetDirectoryName(folder)?.Replace("\\", "/") ?? "Assets"; - var name = Path.GetFileName(folder); - if (string.IsNullOrWhiteSpace(parent) || string.IsNullOrWhiteSpace(name)) + return CreateSuccessResponse(info, "Asset info retrieved"); + } + + private object ManagePackages(AssetParameters args, object rawParams) + { + var subAction = McpEditorHelpers.GetAction(rawParams, "list"); + + if (subAction is "install" or "update" or "remove") { - return CreateErrorResponse($"Invalid folder path: {folder}"); + return CreateErrorResponse( + $"Package {subAction} requires the Package Manager UI or manifest.json editing. " + + $"Use action=list to see installed packages, then edit Packages/manifest.json manually."); } - var guid = AssetDatabase.CreateFolder(parent, name); - var createdPath = AssetDatabase.GUIDToAssetPath(guid); + var manifestPath = Path.Combine(Application.dataPath, "../Packages/manifest.json"); + if (!File.Exists(manifestPath)) + return CreateErrorResponse("Packages/manifest.json not found."); + + var content = File.ReadAllText(manifestPath); + var packages = new List(); + + try + { + var manifest = JObject.Parse(content); + if (manifest["dependencies"] is JObject deps) + { + foreach (var prop in deps.Properties()) + { + packages.Add(new + { + name = prop.Name, + version = prop.Value.ToString(), + isBuiltIn = prop.Name.StartsWith("com.unity.") + }); + } + } + } + catch (Exception e) + { + return CreateErrorResponse($"Failed to parse manifest: {e.Message}"); + } return CreateSuccessResponse(new { - path = createdPath - }, "Folder created"); + totalPackages = packages.Count, + packages = packages.OrderBy(p => ((dynamic)p).name).ToList() + }, "Installed packages listed"); + } + + private object ListAssets(AssetParameters args) + { + var folder = NormalizeFolder(args.folder); + if (folder == null) + return CreateErrorResponse("Folder must be inside the Assets folder."); + + if (!AssetDatabase.IsValidFolder(folder)) + return CreateErrorResponse($"Folder does not exist: {folder}"); + + var assetPaths = AssetDatabase.GetAllAssetPaths() + .Where(p => p.StartsWith(folder, StringComparison.OrdinalIgnoreCase)) + .Where(p => args.includeSubfolders || Path.GetDirectoryName(p) == folder) + .ToArray(); + + var assets = assetPaths.Select(path => new + { + path, + type = AssetDatabase.GetMainAssetTypeAtPath(path)?.Name ?? "Unknown" + }).ToList(); + + return CreateSuccessResponse(new { folder, count = assets.Count, assets }, "Assets listed"); + } + + private object CreateFolder(AssetParameters args) + { + var folder = NormalizeFolder(args.folder ?? args.targetPath ?? args.path); + if (folder == null || string.Equals(folder, "Assets", StringComparison.OrdinalIgnoreCase)) + return CreateErrorResponse("Provide a folder path inside Assets to create."); + + if (AssetDatabase.IsValidFolder(folder)) + return CreateErrorResponse($"Folder already exists: {folder}"); + + var created = McpEditorHelpers.EnsureAssetFolder(folder); + return CreateSuccessResponse(new { path = created }, "Folder created"); } private object MoveAsset(AssetParameters args) { if (string.IsNullOrWhiteSpace(args.assetPath) || string.IsNullOrWhiteSpace(args.destinationPath)) - { - return CreateErrorResponse("assetPath and destinationPath are required to move an asset."); - } + return CreateErrorResponse("assetPath and destinationPath are required."); - var source = NormalizeAssetPath(args.assetPath); - var destination = NormalizeAssetPath(args.destinationPath); + var source = McpEditorHelpers.NormalizeAssetPath(args.assetPath); + var destination = McpEditorHelpers.NormalizeAssetPath(args.destinationPath); if (source == null || destination == null) - { - return CreateErrorResponse("assetPath and destinationPath must be inside the Assets folder."); - } + return CreateErrorResponse("Paths must be inside the Assets folder."); var error = AssetDatabase.MoveAsset(source, destination); if (!string.IsNullOrEmpty(error)) - { return CreateErrorResponse($"Failed to move asset: {error}"); - } - return CreateSuccessResponse(new - { - from = source, - to = destination - }, "Asset moved"); + return CreateSuccessResponse(new { from = source, to = destination }, "Asset moved"); } private object DeleteAsset(AssetParameters args) { if (string.IsNullOrWhiteSpace(args.assetPath)) - { - return CreateErrorResponse("assetPath is required to delete an asset."); - } + return CreateErrorResponse("assetPath is required."); - var assetPath = NormalizeAssetPath(args.assetPath); + var assetPath = McpEditorHelpers.NormalizeAssetPath(args.assetPath); if (assetPath == null) - { return CreateErrorResponse("assetPath must be inside the Assets folder."); - } if (!AssetDatabase.DeleteAsset(assetPath)) - { return CreateErrorResponse($"Failed to delete asset at {assetPath}"); - } - return CreateSuccessResponse(new - { - path = assetPath - }, "Asset deleted"); + return CreateSuccessResponse(new { path = assetPath }, "Asset deleted"); } private string NormalizeFolder(string folder) { - if (string.IsNullOrWhiteSpace(folder)) - { - return "Assets"; - } - + if (string.IsNullOrWhiteSpace(folder)) return "Assets"; var normalized = folder.Replace("\\", "/").TrimEnd('/'); return normalized.StartsWith("Assets", StringComparison.OrdinalIgnoreCase) ? normalized : null; } - - private string NormalizeAssetPath(string assetPath) - { - if (string.IsNullOrWhiteSpace(assetPath)) - { - return null; - } - - var normalized = assetPath.Replace("\\", "/"); - return normalized.StartsWith("Assets", StringComparison.OrdinalIgnoreCase) ? normalized : null; - } } -} +} \ No newline at end of file diff --git a/Unity/Editor/Tools/BuildManagerTool.cs b/Unity/Editor/Tools/BuildManagerTool.cs index d892352..ae34c7e 100644 --- a/Unity/Editor/Tools/BuildManagerTool.cs +++ b/Unity/Editor/Tools/BuildManagerTool.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using UnityEngine; using UnityEditor; using UnityEditor.Build.Reporting; @@ -15,16 +17,52 @@ public class BuildManagerTool : McpToolBase public override string Description => "Manage Unity builds and build settings"; public override string Category => "build"; + private static BuildReport lastBuildReport; + private static readonly List capturedLogs = new List(); + + [Serializable] + private class ConsoleLogEntry + { + public string type; + public string message; + public string timestamp; + } + [Serializable] public class BuildParameters { + public string action = "execute"; public string target = "StandaloneWindows64"; public string buildPath = ""; + public string outputPath = ""; public bool developmentBuild = false; + public bool development = false; public bool scriptDebugging = false; public bool connectProfiler = false; public bool allowDebugging = false; + public bool clean = false; + public bool confirmClean = false; public string[] scenes = null; + public string cleanType = "all"; + public string[] logTypes; + public int limit = 100; + public bool clearAfterRetrieve = false; + } + + static BuildManagerTool() + { + Application.logMessageReceived += OnLogMessage; + } + + private static void OnLogMessage(string message, string stackTrace, LogType type) + { + capturedLogs.Add(new ConsoleLogEntry + { + type = type.ToString(), + message = message, + timestamp = DateTime.UtcNow.ToString("o") + }); + if (capturedLogs.Count > 2000) capturedLogs.RemoveAt(0); } public override object Execute(object parameters) @@ -32,134 +70,312 @@ public override object Execute(object parameters) try { var args = GetParameters(parameters); - - // Parse build target - if (!Enum.TryParse(args.target, out var buildTarget)) + var action = McpEditorHelpers.GetAction(parameters, args.action ?? "execute"); + + return action switch { + "configure" => ConfigureBuild(args), + "execute" => ExecuteBuild(args), + "run_tests" => RunTests(args), + "get_report" => GetBuildReport(args), + "clean" => CleanBuild(args), + "addressables" => AddressablesBuild(args), + "optimize" => OptimizeBuild(args), + "get_console_logs" => GetConsoleLogs(args), + "profile" => ProfileBuild(args), + _ => CreateErrorResponse( + $"Unknown build action '{action}'. Supported: configure, execute, run_tests, get_report, clean, addressables, optimize, get_console_logs, profile") + }; + } + catch (Exception e) + { + LogError($"Build operation failed: {e.Message}"); + return CreateErrorResponse($"Build operation failed: {e.Message}", e.StackTrace); + } + } + + private object ConfigureBuild(BuildParameters args) + { + if (!string.IsNullOrWhiteSpace(args.target)) + { + if (!Enum.TryParse(args.target, out var buildTarget)) return CreateErrorResponse($"Invalid build target: {args.target}"); - } - // Validate build path - if (string.IsNullOrEmpty(args.buildPath)) - { - args.buildPath = GetDefaultBuildPath(buildTarget); - } + var group = BuildPipeline.GetBuildTargetGroup(buildTarget); + EditorUserBuildSettings.SwitchActiveBuildTarget(group, buildTarget); + } - // Ensure build directory exists - var buildDir = Path.GetDirectoryName(args.buildPath); - if (!Directory.Exists(buildDir)) + if (args.scenes != null && args.scenes.Length > 0) + { + var sceneList = new List(); + for (int i = 0; i < args.scenes.Length; i++) { - Directory.CreateDirectory(buildDir); + var path = McpEditorHelpers.NormalizeAssetPath(args.scenes[i]); + if (path != null) + sceneList.Add(new EditorBuildSettingsScene(path, true)); } + EditorBuildSettings.scenes = sceneList.ToArray(); + } - // Configure build options - var buildOptions = BuildOptions.None; - if (args.developmentBuild) buildOptions |= BuildOptions.Development; - if (args.scriptDebugging) buildOptions |= BuildOptions.AllowDebugging; - if (args.connectProfiler) buildOptions |= BuildOptions.ConnectWithProfiler; + EditorUserBuildSettings.development = args.development || args.developmentBuild; + EditorUserBuildSettings.allowDebugging = args.scriptDebugging || args.allowDebugging; + EditorUserBuildSettings.connectProfiler = args.connectProfiler; - // Get scenes to build - var scenesToBuild = args.scenes ?? GetScenesInBuildSettings(); + return CreateSuccessResponse(new + { + activeBuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString(), + sceneCount = EditorBuildSettings.scenes.Length, + development = EditorUserBuildSettings.development + }, "Build configured"); + } - LogMessage($"Starting build for {buildTarget} at {args.buildPath}"); + private object ExecuteBuild(BuildParameters args) + { + if (!CanExecute()) + return CreateErrorResponse("Cannot build while in Play Mode or during compilation."); - // Perform the build - var buildPlayerOptions = new BuildPlayerOptions - { - scenes = scenesToBuild, - locationPathName = args.buildPath, - target = buildTarget, - options = buildOptions - }; + var targetStr = args.target ?? EditorUserBuildSettings.activeBuildTarget.ToString(); + if (!Enum.TryParse(targetStr, out var buildTarget)) + return CreateErrorResponse($"Invalid build target: {targetStr}"); - var report = BuildPipeline.BuildPlayer(buildPlayerOptions); - - var buildResult = new - { - success = report.summary.result == BuildResult.Succeeded, - result = report.summary.result.ToString(), - totalTime = report.summary.totalTime.TotalSeconds, - totalSize = report.summary.totalSize, - buildPath = args.buildPath, - platform = buildTarget.ToString(), - warnings = report.summary.totalWarnings, - errors = report.summary.totalErrors, - steps = GetBuildSteps(report) - }; + var buildPath = !string.IsNullOrWhiteSpace(args.outputPath) + ? args.outputPath + : (!string.IsNullOrWhiteSpace(args.buildPath) ? args.buildPath : GetDefaultBuildPath(buildTarget)); - if (report.summary.result == BuildResult.Succeeded) - { - LogMessage($"Build completed successfully in {report.summary.totalTime.TotalSeconds:F2} seconds"); - return CreateSuccessResponse(buildResult, "Build completed successfully"); - } - else + var buildDir = Path.GetDirectoryName(buildPath); + if (!string.IsNullOrEmpty(buildDir) && !Directory.Exists(buildDir)) + Directory.CreateDirectory(buildDir); + + var buildOptions = BuildOptions.None; + if (args.developmentBuild || args.development) buildOptions |= BuildOptions.Development; + if (args.scriptDebugging) buildOptions |= BuildOptions.AllowDebugging; + if (args.connectProfiler) buildOptions |= BuildOptions.ConnectWithProfiler; + + var scenesToBuild = args.scenes ?? GetScenesInBuildSettings(); + + LogMessage($"Starting build for {buildTarget} at {buildPath}"); + + var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = scenesToBuild, + locationPathName = buildPath, + target = buildTarget, + options = buildOptions + }); + + lastBuildReport = report; + + var buildResult = new + { + success = report.summary.result == BuildResult.Succeeded, + result = report.summary.result.ToString(), + totalTime = report.summary.totalTime.TotalSeconds, + totalSize = report.summary.totalSize, + buildPath, + platform = buildTarget.ToString(), + warnings = report.summary.totalWarnings, + errors = report.summary.totalErrors, + steps = GetBuildSteps(report) + }; + + return report.summary.result == BuildResult.Succeeded + ? CreateSuccessResponse(buildResult, "Build completed successfully") + : CreateErrorResponse($"Build failed: {report.summary.result}", buildResult); + } + + private object RunTests(BuildParameters args) + { + var testRunnerType = Type.GetType("UnityEditor.TestTools.TestRunner.Api.TestRunnerApi, UnityEditor.TestRunner"); + if (testRunnerType == null) + { + return CreateErrorResponse( + "Unity Test Framework is not installed. Add com.unity.test-framework to Packages/manifest.json."); + } + + return CreateSuccessResponse(new + { + status = "queued", + note = "Test execution requires the Unity Test Framework package. " + + "Install com.unity.test-framework and run tests via Window > General > Test Runner, " + + "or use build.runTests after installing the package." + }, "Test runner API available — run tests via Test Runner window"); + } + + private object GetBuildReport(BuildParameters args) + { + if (lastBuildReport == null) + return CreateErrorResponse("No build report available. Run build.execute first."); + + var summary = lastBuildReport.summary; + var data = new Dictionary + { + ["result"] = summary.result.ToString(), + ["totalTime"] = summary.totalTime.TotalSeconds, + ["totalSize"] = summary.totalSize, + ["totalWarnings"] = summary.totalWarnings, + ["totalErrors"] = summary.totalErrors, + ["platform"] = summary.platform.ToString(), + ["outputPath"] = summary.outputPath + }; + + return CreateSuccessResponse(data, "Build report retrieved"); + } + + private object CleanBuild(BuildParameters args) + { + if (!args.confirmClean) + { + return CreateErrorResponse( + "Set confirmClean=true to confirm cleanup. This can delete Library, Temp, or build artifacts."); + } + + var projectRoot = Application.dataPath.Replace("/Assets", "").Replace("\\Assets", ""); + var cleaned = new List(); + var cleanType = (args.cleanType ?? "all").Trim().ToLowerInvariant(); + + void TryDeleteDir(string relativePath) + { + var full = Path.Combine(projectRoot, relativePath); + if (Directory.Exists(full)) { - LogError($"Build failed: {report.summary.result}"); - return CreateErrorResponse($"Build failed: {report.summary.result}", buildResult); + try + { + Directory.Delete(full, true); + cleaned.Add(relativePath); + } + catch (Exception e) + { + LogError($"Could not delete {relativePath}: {e.Message}"); + } } } - catch (Exception e) + + switch (cleanType) { - LogError($"Build operation failed: {e.Message}"); - return CreateErrorResponse($"Build operation failed: {e.Message}", e.StackTrace); + case "temp": + TryDeleteDir("Temp"); + break; + case "builds": + TryDeleteDir("Builds"); + break; + case "logs": + TryDeleteDir("Logs"); + break; + case "library": + return CreateErrorResponse( + "Cleaning Library requires closing Unity. Delete the Library folder manually while Unity is closed."); + case "all": + TryDeleteDir("Temp"); + TryDeleteDir("Builds"); + TryDeleteDir("Logs"); + break; + } + + return CreateSuccessResponse(new { cleanType, cleaned }, $"Cleaned {cleaned.Count} location(s)"); + } + + private object AddressablesBuild(BuildParameters args) + { + var addrType = Type.GetType("UnityEditor.AddressableAssets.Settings.AddressableAssetSettings, Unity.Addressables.Editor"); + if (addrType == null) + { + return CreateErrorResponse( + "Addressables package is not installed. Add com.unity.addressables to Packages/manifest.json."); } + + return CreateSuccessResponse(new + { + note = "Addressables package is installed. Use Window > Asset Management > Addressables to build content." + }, "Addressables package detected"); + } + + private object OptimizeBuild(BuildParameters args) + { + var suggestions = new List + { + "Enable IL2CPP for release mobile builds to improve performance.", + "Use asset bundles or Addressables to reduce initial build size.", + "Review texture import settings — set max size appropriately per platform.", + "Disable Development Build for release builds.", + "Strip unused mesh components and disable Read/Write on textures not used at runtime." + }; + + return CreateSuccessResponse(new + { + activeBuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString(), + suggestions, + textureCount = AssetDatabase.FindAssets("t:Texture").Length, + meshCount = AssetDatabase.FindAssets("t:Mesh").Length + }, "Build optimization suggestions generated"); + } + + private object GetConsoleLogs(BuildParameters args) + { + var types = args.logTypes ?? new[] { "Log", "Warning", "Error" }; + var typeSet = new HashSet(types, StringComparer.OrdinalIgnoreCase); + + var logs = capturedLogs + .Where(l => typeSet.Contains(l.type)) + .TakeLast(Mathf.Clamp(args.limit, 1, 500)) + .ToList(); + + if (args.clearAfterRetrieve) + capturedLogs.Clear(); + + return CreateSuccessResponse(new + { + logs, + totalCaptured = capturedLogs.Count, + returned = logs.Count + }, $"Retrieved {logs.Count} log entries"); + } + + private object ProfileBuild(BuildParameters args) + { + return CreateSuccessResponse(new + { + editorMetrics = new + { + isCompiling = EditorApplication.isCompiling, + isPlaying = EditorApplication.isPlaying, + timeSinceStartup = EditorApplication.timeSinceStartup + }, + note = "For detailed build profiling, use the Build Report package (com.unity.build-report-inspector) " + + "or inspect the last build report via build.getReport after build.execute." + }, "Build profile info retrieved"); } private string GetDefaultBuildPath(BuildTarget target) { var projectName = Application.productName; var extension = GetBuildExtension(target); - var platformFolder = target.ToString(); - - return Path.Combine("Builds", platformFolder, $"{projectName}{extension}"); + return Path.Combine("Builds", target.ToString(), $"{projectName}{extension}"); } private string GetBuildExtension(BuildTarget target) { - switch (target) - { - case BuildTarget.StandaloneWindows: - case BuildTarget.StandaloneWindows64: - return ".exe"; - case BuildTarget.StandaloneOSX: - return ".app"; - case BuildTarget.StandaloneLinux64: - return ""; - case BuildTarget.Android: - return ".apk"; - case BuildTarget.iOS: - return ""; - case BuildTarget.WebGL: - return ""; - default: - return ""; - } + return target switch + { + BuildTarget.StandaloneWindows or BuildTarget.StandaloneWindows64 => ".exe", + BuildTarget.StandaloneOSX => ".app", + BuildTarget.Android => ".apk", + _ => "" + }; } private string[] GetScenesInBuildSettings() { - var scenes = new string[EditorBuildSettings.scenes.Length]; - for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) - { - scenes[i] = EditorBuildSettings.scenes[i].path; - } - return scenes; + return EditorBuildSettings.scenes.Select(s => s.path).ToArray(); } private object[] GetBuildSteps(BuildReport report) { - var steps = new object[report.steps.Length]; - for (int i = 0; i < report.steps.Length; i++) + return report.steps.Select(step => (object)new { - var step = report.steps[i]; - steps[i] = new - { - name = step.name, - duration = step.duration.TotalSeconds, - messages = step.messages?.Length ?? 0 - }; - } - return steps; + name = step.name, + duration = step.duration.TotalSeconds, + messages = step.messages?.Length ?? 0 + }).ToArray(); } public override bool CanExecute() @@ -167,4 +383,4 @@ public override bool CanExecute() return !EditorApplication.isPlaying && !EditorApplication.isCompiling; } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/Unity/Editor/Tools/CodeGenerationTool.cs b/Unity/Editor/Tools/CodeGenerationTool.cs index 9947cd2..c81d35a 100644 --- a/Unity/Editor/Tools/CodeGenerationTool.cs +++ b/Unity/Editor/Tools/CodeGenerationTool.cs @@ -1,5 +1,10 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; using UnityEngine; using UnityEditor; @@ -19,11 +24,23 @@ public class CodeGenerationParameters { public string action = "create_script"; public string scriptName = "NewScript"; + public string name = ""; public string folderPath = "Assets"; + public string savePath = ""; public string @namespace = ""; public string baseClass = "MonoBehaviour"; - public string template = ""; + public string template = "MonoBehaviour"; public bool overwrite = false; + public string scriptPath = ""; + public string directory = ""; + public string gameObjectName = ""; + public int gameObjectId = 0; + public string searchType = "script"; + public string searchTerm = ""; + public string operation = "rename"; + public string oldName = ""; + public string newName = ""; + public string methodName = ""; } public override object Execute(object parameters) @@ -31,87 +48,313 @@ public override object Execute(object parameters) try { var args = GetParameters(parameters); - var action = (args.action ?? "create_script").Trim().ToLowerInvariant(); + var action = McpEditorHelpers.GetAction(parameters, args.action ?? "create_script"); return action switch { "create_script" => CreateScript(args), "list_templates" => ListTemplates(), - _ => CreateErrorResponse($"Unknown action '{args.action}'. Supported actions: create_script, list_templates") + "analyze_scripts" => AnalyzeScripts(args), + "attach_script" => AttachScript(args), + "find_references" => FindReferences(args), + "refactor" => RefactorScript(args), + "generate_documentation" => GenerateDocumentation(args), + "validate" => ValidateScripts(args), + "format" => FormatScripts(args), + _ => CreateErrorResponse( + $"Unknown code action '{action}'. Supported: create_script, analyze_scripts, attach_script, find_references, refactor, generate_documentation, validate, format") }; } catch (Exception e) { - LogError($"Code generation failed: {e.Message}"); - return CreateErrorResponse($"Code generation failed: {e.Message}", e.StackTrace); + LogError($"Code operation failed: {e.Message}"); + return CreateErrorResponse($"Code operation failed: {e.Message}", e.StackTrace); } } private object CreateScript(CodeGenerationParameters args) { - if (string.IsNullOrWhiteSpace(args.scriptName)) + var scriptName = !string.IsNullOrWhiteSpace(args.name) ? args.name : args.scriptName; + if (string.IsNullOrWhiteSpace(scriptName)) + return CreateErrorResponse("name/scriptName is required."); + + var folder = !string.IsNullOrWhiteSpace(args.savePath) ? args.savePath : args.folderPath; + var safeFolder = McpEditorHelpers.EnsureAssetFolder(folder); + + var targetPath = $"{safeFolder}/{scriptName}.cs"; + var exists = File.Exists(targetPath); + + if (exists && !args.overwrite) + return CreateErrorResponse($"Script '{scriptName}' already exists. Set overwrite=true to replace."); + + var baseClass = args.baseClass; + if (!string.IsNullOrWhiteSpace(args.template) && string.IsNullOrWhiteSpace(args.baseClass)) { - return CreateErrorResponse("Script name is required."); + baseClass = args.template switch + { + "ScriptableObject" => "ScriptableObject", + "Interface" => null, + "Enum" => null, + "Class" => null, + _ => "MonoBehaviour" + }; } - var safeFolder = string.IsNullOrWhiteSpace(args.folderPath) ? "Assets" : args.folderPath.Trim(); - if (!safeFolder.StartsWith("Assets", StringComparison.OrdinalIgnoreCase)) + var contents = BuildTemplate(scriptName, args.@namespace, baseClass ?? "MonoBehaviour", args.template); + File.WriteAllText(targetPath, contents); + AssetDatabase.ImportAsset(targetPath); + + return CreateSuccessResponse(new + { + path = targetPath, + name = scriptName, + folder = safeFolder + }, "Script created"); + } + + private object AnalyzeScripts(CodeGenerationParameters args) + { + var paths = ResolveScriptPaths(args); + if (paths.Count == 0) + return CreateErrorResponse("scriptPath or directory is required."); + + var results = new List(); + foreach (var path in paths) + { + var content = File.ReadAllText(path); + var classes = Regex.Matches(content, @"(public|internal)\s+(partial\s+)?class\s+(\w+)") + .Cast() + .Select(m => m.Groups[3].Value) + .ToArray(); + + results.Add(new + { + path, + lineCount = content.Split('\n').Length, + classes, + hasMonoBehaviour = content.Contains(": MonoBehaviour"), + hasScriptableObject = content.Contains(": ScriptableObject"), + usingCount = Regex.Matches(content, @"^using\s+", RegexOptions.Multiline).Count, + summaryCommentCount = Regex.Matches(content, @"///\s*").Count + }); + } + + return CreateSuccessResponse(new { analyzed = results.Count, scripts = results }, "Script analysis complete"); + } + + private object AttachScript(CodeGenerationParameters args) + { + var go = McpEditorHelpers.FindGameObject(args.gameObjectName, args.gameObjectId); + if (go == null) + return CreateErrorResponse("gameObjectName or gameObjectId is required."); + + Type scriptType = null; + if (!string.IsNullOrWhiteSpace(args.scriptPath)) { - return CreateErrorResponse("Folder path must be inside the Assets folder."); + var monoScript = AssetDatabase.LoadAssetAtPath(McpEditorHelpers.NormalizeAssetPath(args.scriptPath)); + if (monoScript != null) + scriptType = monoScript.GetClass(); } - if (!AssetDatabase.IsValidFolder(safeFolder)) + if (scriptType == null && !string.IsNullOrWhiteSpace(args.scriptName)) { - var parent = "Assets"; - var child = safeFolder.Replace("Assets/", ""); - if (string.IsNullOrWhiteSpace(child)) + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { - return CreateErrorResponse($"Folder does not exist: {safeFolder}"); + scriptType = assembly.GetTypes().FirstOrDefault(t => + t.Name == args.scriptName && typeof(MonoBehaviour).IsAssignableFrom(t)); + if (scriptType != null) break; } + } + + if (scriptType == null) + return CreateErrorResponse("Could not resolve script type. Provide scriptPath to a compiled script."); + + if (go.GetComponent(scriptType) != null) + return CreateErrorResponse($"Script '{scriptType.Name}' is already attached to '{go.name}'."); + + var component = Undo.AddComponent(go, scriptType); + return CreateSuccessResponse(new + { + gameObject = go.name, + script = scriptType.Name, + component = component.GetType().Name + }, "Script attached"); + } - var parts = child.Split('/'); - foreach (var part in parts) + private object FindReferences(CodeGenerationParameters args) + { + if (string.IsNullOrWhiteSpace(args.searchTerm)) + return CreateErrorResponse("searchTerm is required."); + + var searchPaths = string.IsNullOrWhiteSpace(args.directory) + ? AssetDatabase.FindAssets("t:Script", new[] { "Assets" }) + .Select(AssetDatabase.GUIDToAssetPath) + .ToList() + : ResolveScriptPaths(args); + + var references = new List(); + foreach (var path in searchPaths) + { + var content = File.ReadAllText(path); + if (content.IndexOf(args.searchTerm, StringComparison.OrdinalIgnoreCase) >= 0) { - var next = $"{parent}/{part}"; - if (!AssetDatabase.IsValidFolder(next)) + var lines = content.Split('\n'); + var matchingLines = new List(); + for (int i = 0; i < lines.Length; i++) { - AssetDatabase.CreateFolder(parent, part); + if (lines[i].IndexOf(args.searchTerm, StringComparison.OrdinalIgnoreCase) >= 0) + matchingLines.Add(i + 1); } - parent = next; + + references.Add(new { path, matchingLines }); } } - var fileName = $"{args.scriptName}.cs"; - var targetPath = $"{safeFolder}/{fileName}"; - var scriptPath = targetPath; - var exists = File.Exists(targetPath); + return CreateSuccessResponse(new + { + searchTerm = args.searchTerm, + matchCount = references.Count, + references + }, $"Found {references.Count} file(s) referencing '{args.searchTerm}'"); + } - if (exists && !args.overwrite) + private object RefactorScript(CodeGenerationParameters args) + { + if (string.IsNullOrWhiteSpace(args.scriptPath)) + return CreateErrorResponse("scriptPath is required."); + + var path = McpEditorHelpers.NormalizeAssetPath(args.scriptPath); + if (path == null || !File.Exists(path)) + return CreateErrorResponse($"Script not found: {args.scriptPath}"); + + var operation = (args.operation ?? "rename").Trim().ToLowerInvariant(); + + if (operation != "rename") + return CreateErrorResponse($"Refactor operation '{operation}' is not yet implemented. Supported: rename."); + + if (string.IsNullOrWhiteSpace(args.oldName) || string.IsNullOrWhiteSpace(args.newName)) + return CreateErrorResponse("oldName and newName are required for rename."); + + var content = File.ReadAllText(path); + var pattern = $@"\b{Regex.Escape(args.oldName)}\b"; + var newContent = Regex.Replace(content, pattern, args.newName); + var replacements = Regex.Matches(content, pattern).Count; + + if (replacements == 0) + return CreateErrorResponse($"No occurrences of '{args.oldName}' found in {path}."); + + File.WriteAllText(path, newContent); + AssetDatabase.ImportAsset(path); + + return CreateSuccessResponse(new { - return CreateErrorResponse($"A script named '{args.scriptName}' already exists in {safeFolder}. Set overwrite to true to replace."); - } + path, + oldName = args.oldName, + newName = args.newName, + replacements + }, $"Renamed {replacements} occurrence(s)"); + } - if (!exists && !args.overwrite) + private object GenerateDocumentation(CodeGenerationParameters args) + { + var paths = ResolveScriptPaths(args); + if (paths.Count == 0) + return CreateErrorResponse("scriptPath or directory is required."); + + var documented = 0; + foreach (var path in paths) { - scriptPath = AssetDatabase.GenerateUniqueAssetPath(targetPath); + var content = File.ReadAllText(path); + if (!content.Contains("/// ")) + { + var classMatch = Regex.Match(content, @"(public\s+(?:partial\s+)?class\s+)(\w+)"); + if (classMatch.Success) + { + var insertion = $"/// \n/// {classMatch.Groups[2].Value} — auto-generated documentation.\n/// \n"; + content = content.Insert(classMatch.Index, insertion); + File.WriteAllText(path, content); + AssetDatabase.ImportAsset(path); + documented++; + } + } } - var contents = string.IsNullOrWhiteSpace(args.template) - ? BuildDefaultTemplate(args.scriptName, args.@namespace, args.baseClass) - : args.template.Replace("{SCRIPT_NAME}", args.scriptName) - .Replace("{NAMESPACE}", args.@namespace ?? "") - .Replace("{BASE_CLASS}", args.baseClass ?? "MonoBehaviour"); + return CreateSuccessResponse(new { filesProcessed = paths.Count, documented }, "Documentation generation complete"); + } + + private object ValidateScripts(CodeGenerationParameters args) + { + if (EditorApplication.isCompiling) + return CreateSuccessResponse(new { compiling = true }, "Scripts are currently compiling."); - System.IO.File.WriteAllText(scriptPath, contents); - AssetDatabase.ImportAsset(scriptPath); - LogMessage($"Created script at {scriptPath}"); + var paths = ResolveScriptPaths(args); + var issues = new List(); + + foreach (var path in paths) + { + var content = File.ReadAllText(path); + if (content.Contains("TODO") || content.Contains("FIXME")) + issues.Add(new { path, type = "info", message = "Contains TODO/FIXME markers" }); + if (content.Contains("FindObjectOfType") && !content.Contains("FindObjectOfType<")) + issues.Add(new { path, type = "warning", message = "Uses deprecated FindObjectOfType pattern" }); + if (content.Contains("Update()") && content.Contains("MonoBehaviour")) + issues.Add(new { path, type = "info", message = "Contains Update() — consider event-driven alternatives" }); + } return CreateSuccessResponse(new { - path = scriptPath, - name = args.scriptName, - folder = safeFolder - }, "Script created successfully"); + filesChecked = paths.Count, + issueCount = issues.Count, + issues + }, $"Validation complete — {issues.Count} note(s)"); + } + + private object FormatScripts(CodeGenerationParameters args) + { + var paths = ResolveScriptPaths(args); + if (paths.Count == 0) + return CreateErrorResponse("scriptPath or directory is required."); + + var formatted = 0; + foreach (var path in paths) + { + var lines = File.ReadAllLines(path); + var sb = new StringBuilder(); + foreach (var line in lines) + sb.AppendLine(line.TrimEnd()); + + var result = sb.ToString().TrimEnd() + Environment.NewLine; + if (result != File.ReadAllText(path)) + { + File.WriteAllText(path, result); + formatted++; + } + } + + AssetDatabase.Refresh(); + return CreateSuccessResponse(new { filesProcessed = paths.Count, formatted }, "Format complete"); + } + + private List ResolveScriptPaths(CodeGenerationParameters args) + { + var paths = new List(); + + if (!string.IsNullOrWhiteSpace(args.scriptPath)) + { + var p = McpEditorHelpers.NormalizeAssetPath(args.scriptPath); + if (p != null && File.Exists(p)) paths.Add(p); + } + + var dir = args.directory; + if (!string.IsNullOrWhiteSpace(dir)) + { + var folder = McpEditorHelpers.NormalizeAssetPath(dir) ?? "Assets"; + paths.AddRange( + AssetDatabase.FindAssets("t:Script", new[] { folder }) + .Select(AssetDatabase.GUIDToAssetPath)); + } + + return paths.Distinct().ToList(); } private object ListTemplates() @@ -120,30 +363,48 @@ private object ListTemplates() { templates = new[] { - new - { - name = "Default MonoBehaviour", - description = "Basic MonoBehaviour template", - placeholders = new[] { "{SCRIPT_NAME}", "{NAMESPACE}", "{BASE_CLASS}" } - } + new { name = "MonoBehaviour", baseClass = "MonoBehaviour" }, + new { name = "ScriptableObject", baseClass = "ScriptableObject" }, + new { name = "Class", baseClass = "object" }, + new { name = "Interface", baseClass = "(interface)" }, + new { name = "Enum", baseClass = "(enum)" } } }, "Available templates listed"); } - private string BuildDefaultTemplate(string scriptName, string scriptNamespace, string baseClass) + private string BuildTemplate(string scriptName, string scriptNamespace, string baseClass, string template) { - var namespaceLine = string.IsNullOrWhiteSpace(scriptNamespace) - ? "" - : $"namespace {scriptNamespace}\n{{\n"; + if (template == "Enum") + { + return string.IsNullOrWhiteSpace(scriptNamespace) + ? $"public enum {scriptName}\n{{\n Value1,\n Value2\n}}\n" + : $"namespace {scriptNamespace}\n{{\n public enum {scriptName}\n {{\n Value1,\n Value2\n }}\n}}\n"; + } + + if (template == "Interface") + { + return string.IsNullOrWhiteSpace(scriptNamespace) + ? $"public interface {scriptName}\n{{\n}}\n" + : $"namespace {scriptNamespace}\n{{\n public interface {scriptName}\n {{\n }}\n}}\n"; + } - var namespaceClose = string.IsNullOrWhiteSpace(scriptNamespace) ? "" : "\n}"; + var nsOpen = string.IsNullOrWhiteSpace(scriptNamespace) ? "" : $"namespace {scriptNamespace}\n{{\n"; + var nsClose = string.IsNullOrWhiteSpace(scriptNamespace) ? "" : "}\n"; var indent = string.IsNullOrWhiteSpace(scriptNamespace) ? "" : " "; - var parentClass = string.IsNullOrWhiteSpace(baseClass) ? "MonoBehaviour" : baseClass; - return -$@"using UnityEngine; + if (baseClass == "ScriptableObject") + { + return $@"using UnityEngine; + +{nsOpen}{indent}[CreateAssetMenu(fileName = ""{scriptName}"", menuName = ""Custom/{scriptName}"")] +{indent}public class {scriptName} : ScriptableObject +{indent}{{ +{indent}}}{nsClose}"; + } + + return $@"using UnityEngine; -{namespaceLine}{indent}public class {scriptName} : {parentClass} +{nsOpen}{indent}public class {scriptName} : {baseClass} {indent}{{ {indent} private void Start() {indent} {{ @@ -152,8 +413,7 @@ private string BuildDefaultTemplate(string scriptName, string scriptNamespace, s {indent} private void Update() {indent} {{ {indent} }} -{indent}}}{namespaceClose} -"; +{indent}}}{nsClose}"; } } -} +} \ No newline at end of file diff --git a/Unity/Editor/Tools/PlayModeTool.cs b/Unity/Editor/Tools/PlayModeTool.cs index 70fde51..8c7c7a7 100644 --- a/Unity/Editor/Tools/PlayModeTool.cs +++ b/Unity/Editor/Tools/PlayModeTool.cs @@ -109,22 +109,23 @@ public override object Execute(object parameters) EnsureLogHandlerRegistered(); - return args.action switch + var action = (args.action ?? "").Trim().ToLowerInvariant(); + return action switch { - "getState" => GetPlayModeState(), + "getstate" or "get_state" => GetPlayModeState(), "enter" => EnterPlayMode(args), "exit" => ExitPlayMode(), "pause" => PausePlayMode(), "resume" => ResumePlayMode(), "step" => StepFrame(args), - "inspectGameObject" => InspectGameObject(args), - "setProperty" => SetProperty(args), - "invokeMethod" => InvokeMethod(args), - "getConsoleLogs" => GetConsoleLogs(args), - "getRuntimeInfo" => GetRuntimeInfo(args), - "setTimeScale" => SetTimeScale(args), + "inspectgameobject" or "inspect_game_object" => InspectGameObject(args), + "setproperty" or "set_property" => SetProperty(args), + "invokemethod" or "invoke_method" => InvokeMethod(args), + "getconsolelogs" or "get_console_logs" => GetConsoleLogs(args), + "getruntimeinfo" or "get_runtime_info" => GetRuntimeInfo(args), + "settimescale" or "set_time_scale" => SetTimeScale(args), "observe" => ObserveRuntime(args), - "simulateInput" => SimulateInput(args), + "simulateinput" or "simulate_input" => SimulateInput(args), _ => CreateErrorResponse($"Unknown playmode action: {args.action}") }; } @@ -337,7 +338,7 @@ private Dictionary BuildGameObjectInspection(GameObject go, stri var info = new Dictionary { ["name"] = go.name, - ["instanceId"] = go.GetEntityId(), + ["instanceId"] = UnityCompat.GetObjectId(go), ["active"] = go.activeSelf, ["activeInHierarchy"] = go.activeInHierarchy, ["tag"] = go.tag, @@ -400,11 +401,11 @@ private Dictionary ExtractComponentProperties(Component componen if (component is Rigidbody rb) { - props["velocity"] = Vec3(rb.linearVelocity); - props["angularVelocity"] = Vec3(rb.angularVelocity); + props["velocity"] = Vec3(UnityCompat.GetRigidbodyVelocity(rb)); + props["angularVelocity"] = Vec3(UnityCompat.GetRigidbodyAngularVelocity(rb)); props["mass"] = rb.mass; - props["drag"] = rb.linearDamping; - props["angularDrag"] = rb.angularDamping; + props["drag"] = UnityCompat.GetRigidbodyDrag(rb); + props["angularDrag"] = UnityCompat.GetRigidbodyAngularDrag(rb); props["useGravity"] = rb.useGravity; props["isKinematic"] = rb.isKinematic; props["isSleeping"] = rb.IsSleeping(); @@ -413,7 +414,7 @@ private Dictionary ExtractComponentProperties(Component componen if (component is Rigidbody2D rb2d) { - props["velocity"] = Vec2(rb2d.linearVelocity); + props["velocity"] = Vec2(UnityCompat.GetRigidbody2DVelocity(rb2d)); props["angularVelocity"] = rb2d.angularVelocity; props["mass"] = rb2d.mass; props["gravityScale"] = rb2d.gravityScale; @@ -1057,11 +1058,11 @@ private static object ReadSingleProperty(Component comp, string propertyName) { return propertyName.ToLower() switch { - "velocity" or "linearvelocity" => Vec3Static(rb.linearVelocity), - "angularvelocity" => Vec3Static(rb.angularVelocity), + "velocity" or "linearvelocity" => Vec3Static(UnityCompat.GetRigidbodyVelocity(rb)), + "angularvelocity" => Vec3Static(UnityCompat.GetRigidbodyAngularVelocity(rb)), "position" => Vec3Static(rb.position), "mass" => rb.mass, - "drag" or "lineardamping" => rb.linearDamping, + "drag" or "lineardamping" => UnityCompat.GetRigidbodyDrag(rb), "issleeping" => rb.IsSleeping(), _ => null }; @@ -1071,7 +1072,7 @@ private static object ReadSingleProperty(Component comp, string propertyName) { return propertyName.ToLower() switch { - "velocity" or "linearvelocity" => Vec2Static(rb2d.linearVelocity), + "velocity" or "linearvelocity" => Vec2Static(UnityCompat.GetRigidbody2DVelocity(rb2d)), "angularvelocity" => rb2d.angularVelocity, "position" => Vec2Static(rb2d.position), "mass" => rb2d.mass, @@ -1128,15 +1129,15 @@ private static Dictionary SampleAllComponentProperties(Component if (comp is Rigidbody rb) { - props["velocity"] = Vec3Static(rb.linearVelocity); - props["angularVelocity"] = Vec3Static(rb.angularVelocity); + props["velocity"] = Vec3Static(UnityCompat.GetRigidbodyVelocity(rb)); + props["angularVelocity"] = Vec3Static(UnityCompat.GetRigidbodyAngularVelocity(rb)); props["position"] = Vec3Static(rb.position); return props; } if (comp is Rigidbody2D rb2d) { - props["velocity"] = Vec2Static(rb2d.linearVelocity); + props["velocity"] = Vec2Static(UnityCompat.GetRigidbody2DVelocity(rb2d)); props["position"] = Vec2Static(rb2d.position); return props; } diff --git a/Unity/Editor/Tools/ProjectAnalyzerTool.cs b/Unity/Editor/Tools/ProjectAnalyzerTool.cs index bedfc70..dfe7079 100644 --- a/Unity/Editor/Tools/ProjectAnalyzerTool.cs +++ b/Unity/Editor/Tools/ProjectAnalyzerTool.cs @@ -20,40 +20,203 @@ public class ProjectAnalyzerTool : McpToolBase public override string Category => "project"; [Serializable] - public class AnalyzeParameters + public class ProjectParameters { + public string action = "analyze"; public bool includeAssets = false; public bool includePackages = true; public bool includeScenes = true; public bool includeSettings = true; + public bool includeDisabled = false; + public bool forceReimport = false; + public string companyName; + public string productName; + public string version; + public string bundleVersion; + public string target; } public override object Execute(object parameters) { try { - var args = GetParameters(parameters); - LogMessage("Starting project analysis..."); + var args = GetParameters(parameters); + var action = McpEditorHelpers.GetAction(parameters, args.action ?? "analyze"); - var projectInfo = new + return action switch { - project = GetProjectInfo(), - settings = args.includeSettings ? GetProjectSettings() : null, - scenes = args.includeScenes ? GetSceneInfo() : null, - packages = args.includePackages ? GetPackageInfo() : null, - assets = args.includeAssets ? GetAssetInfo() : null, - buildSettings = GetBuildSettings(), - performance = GetPerformanceMetrics() + "analyze" => RunAnalyze(args), + "get_info" => CreateSuccessResponse(GetProjectInfo(), "Project info retrieved"), + "set_settings" => SetSettings(args), + "list_scenes" => ListScenes(args), + "get_build_settings" => GetBuildSettingsResponse(), + "set_build_target" => SetBuildTarget(args), + "refresh_assets" => RefreshAssets(args), + _ => CreateErrorResponse( + $"Unknown project action '{action}'. Supported: analyze, get_info, set_settings, list_scenes, get_build_settings, set_build_target, refresh_assets") }; - - LogMessage("Project analysis completed successfully"); - return CreateSuccessResponse(projectInfo, "Project analysis completed"); } catch (Exception e) { - LogError($"Project analysis failed: {e.Message}"); - return CreateErrorResponse($"Project analysis failed: {e.Message}", e.StackTrace); + LogError($"Project operation failed: {e.Message}"); + return CreateErrorResponse($"Project operation failed: {e.Message}", e.StackTrace); + } + } + + private object RunAnalyze(ProjectParameters args) + { + LogMessage("Starting project analysis..."); + + var projectInfo = new + { + project = GetProjectInfo(), + settings = args.includeSettings ? GetProjectSettings() : null, + scenes = args.includeScenes ? GetSceneInfo() : null, + packages = args.includePackages ? GetPackageInfo() : null, + assets = args.includeAssets ? GetAssetInfo() : null, + buildSettings = GetBuildSettings(), + performance = GetPerformanceMetrics() + }; + + LogMessage("Project analysis completed successfully"); + return CreateSuccessResponse(projectInfo, "Project analysis completed"); + } + + private object SetSettings(ProjectParameters args) + { + var changes = new List(); + + if (!string.IsNullOrWhiteSpace(args.companyName)) + { + PlayerSettings.companyName = args.companyName; + changes.Add($"companyName={args.companyName}"); + } + + if (!string.IsNullOrWhiteSpace(args.productName)) + { + PlayerSettings.productName = args.productName; + changes.Add($"productName={args.productName}"); + } + + if (!string.IsNullOrWhiteSpace(args.bundleVersion)) + { + PlayerSettings.bundleVersion = args.bundleVersion; + changes.Add($"bundleVersion={args.bundleVersion}"); + } + + if (!string.IsNullOrWhiteSpace(args.version)) + { + PlayerSettings.bundleVersion = args.version; + changes.Add($"version={args.version}"); + } + + if (changes.Count == 0) + return CreateErrorResponse("No settings provided to update."); + + AssetDatabase.SaveAssets(); + return CreateSuccessResponse(new { updated = changes }, "Project settings updated"); + } + + private object ListScenes(ProjectParameters args) + { + var scenes = new List(); + + foreach (var guid in AssetDatabase.FindAssets("t:Scene", new[] { "Assets" })) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var inBuild = false; + var buildIndex = -1; + var enabled = false; + + for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) + { + if (EditorBuildSettings.scenes[i].path == path) + { + inBuild = true; + buildIndex = i; + enabled = EditorBuildSettings.scenes[i].enabled; + break; + } + } + + if (!inBuild && !args.includeDisabled) continue; + + scenes.Add(new + { + name = System.IO.Path.GetFileNameWithoutExtension(path), + path, + inBuildSettings = inBuild, + buildIndex, + enabled, + isLoaded = IsSceneLoaded(path), + isDirty = IsSceneDirty(path) + }); + } + + return CreateSuccessResponse(new + { + totalScenes = scenes.Count, + activeScene = EditorSceneManager.GetActiveScene().name, + scenes + }, "Scenes listed"); + } + + private object GetBuildSettingsResponse() + { + var sceneList = new List(); + for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) + { + var s = EditorBuildSettings.scenes[i]; + sceneList.Add(new { path = s.path, enabled = s.enabled, buildIndex = i }); } + + return CreateSuccessResponse(new + { + buildSettings = GetBuildSettings(), + playerSettings = new + { + PlayerSettings.companyName, + PlayerSettings.productName, + PlayerSettings.bundleVersion, + applicationIdentifier = PlayerSettings.applicationIdentifier + }, + scenesInBuild = sceneList + }, "Build settings retrieved"); + } + + private object SetBuildTarget(ProjectParameters args) + { + if (string.IsNullOrWhiteSpace(args.target)) + return CreateErrorResponse("target is required (e.g. StandaloneWindows64, Android, iOS)."); + + if (!Enum.TryParse(args.target, out var buildTarget)) + return CreateErrorResponse($"Invalid build target: {args.target}"); + + var group = BuildPipeline.GetBuildTargetGroup(buildTarget); + if (!BuildPipeline.IsBuildTargetSupported(group, buildTarget)) + return CreateErrorResponse($"Build target {buildTarget} is not supported on this machine."); + + EditorUserBuildSettings.SwitchActiveBuildTarget(group, buildTarget); + + return CreateSuccessResponse(new + { + activeBuildTarget = buildTarget.ToString(), + buildTargetGroup = group.ToString() + }, $"Build target set to {buildTarget}"); + } + + private object RefreshAssets(ProjectParameters args) + { + if (args.forceReimport) + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); + else + AssetDatabase.Refresh(); + + return CreateSuccessResponse(new + { + refreshed = true, + forceReimport = args.forceReimport + }, "Asset database refreshed"); } private object GetProjectInfo() @@ -126,8 +289,10 @@ private string GetDefaultPhysicsMaterial() var defaultMaterialProperty = physicsType.GetProperty("defaultMaterial"); if (defaultMaterialProperty != null) { - var material = defaultMaterialProperty.GetValue(null) as PhysicMaterial; - return material?.name ?? "None"; + // Cast to the base UnityEngine.Object: the concrete type was renamed + // PhysicMaterial -> PhysicsMaterial in Unity 6, and we only read .name. + var material = defaultMaterialProperty.GetValue(null) as UnityEngine.Object; + return material != null ? material.name : "None"; } return "Not Available"; } @@ -162,7 +327,7 @@ private object GetSceneInfo() { totalScenes = scenes.Count, activeScene = EditorSceneManager.GetActiveScene().name, - loadedScenes = EditorSceneManager.loadedSceneCount, + loadedScenes = UnityEngine.SceneManagement.SceneManager.loadedSceneCount, scenes = scenes }; } @@ -256,7 +421,11 @@ private object GetBuildSettings() connectProfiler = EditorUserBuildSettings.connectProfiler, buildScriptsOnly = EditorUserBuildSettings.buildScriptsOnly, allowDebugging = EditorUserBuildSettings.allowDebugging, +#if UNITY_6000_0_OR_NEWER + symlinkLibraries = EditorUserBuildSettings.symlinkSources, +#else symlinkLibraries = EditorUserBuildSettings.symlinkLibraries, +#endif exportAsGoogleAndroidProject = EditorUserBuildSettings.exportAsGoogleAndroidProject }; } @@ -291,9 +460,9 @@ private object GetPerformanceMetrics() private bool IsSceneLoaded(string scenePath) { - for (int i = 0; i < EditorSceneManager.loadedSceneCount; i++) + for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.loadedSceneCount; i++) { - var loadedScene = EditorSceneManager.GetSceneAt(i); + var loadedScene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); if (loadedScene.path == scenePath) return true; } @@ -302,9 +471,9 @@ private bool IsSceneLoaded(string scenePath) private bool IsSceneDirty(string scenePath) { - for (int i = 0; i < EditorSceneManager.loadedSceneCount; i++) + for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.loadedSceneCount; i++) { - var loadedScene = EditorSceneManager.GetSceneAt(i); + var loadedScene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); if (loadedScene.path == scenePath) return loadedScene.isDirty; } diff --git a/Unity/Editor/Tools/SceneManipulationTool.cs b/Unity/Editor/Tools/SceneManipulationTool.cs index f7c722f..f4d7b1b 100644 --- a/Unity/Editor/Tools/SceneManipulationTool.cs +++ b/Unity/Editor/Tools/SceneManipulationTool.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; using UnityEngine; using UnityEditor; using UnityEditor.SceneManagement; @@ -22,25 +24,57 @@ public class SceneParameters public string name = "New Game Object"; public string parentName = ""; public string targetName = ""; + public string gameObjectName = ""; + public int gameObjectId = 0; + public int instanceId = 0; public float[] position = null; public float[] rotation = null; public float[] scale = null; + public bool relative = false; + public string[] components; + public string tag; + public int layer = -1; + public string filter = ""; + public bool includeInactive = false; + public int maxDepth = -1; + public string[] objectNames; + public int[] instanceIds; + public string path = ""; + public string scenePath = ""; + public bool additive = false; + public string componentType = ""; + public string componentAction = ""; + public string primitiveType = ""; + public JObject properties; } public override object Execute(object parameters) { try { - var args = GetParameters(parameters); + var args = NormalizeParameters(parameters); var action = (args.action ?? "list_root_objects").Trim().ToLowerInvariant(); + // Node sends action=add/remove/modify for modifyComponent; routing may not override it. + if (action is "add" or "remove" or "modify" && !string.IsNullOrWhiteSpace(args.componentType)) + { + args.componentAction = action; + action = "modify_component"; + } + return action switch { "create_object" => CreateObject(args), + "create_primitive" => CreatePrimitive(args), "delete_object" => DeleteObject(args), "set_transform" => SetTransform(args), - "list_root_objects" => ListRootObjects(), - _ => CreateErrorResponse($"Unknown action '{args.action}'. Supported actions: create_object, delete_object, set_transform, list_root_objects") + "modify_component" => ModifyComponent(args, parameters), + "list_root_objects" or "query" => QueryScene(args), + "select_objects" => SelectObjects(args), + "save" => SaveScene(args), + "load" => LoadScene(args), + _ => CreateErrorResponse( + $"Unknown action '{args.action}'. Supported: create_object, create_primitive, delete_object, set_transform, modify_component, query, select_objects, save, load") }; } catch (Exception e) @@ -52,64 +86,106 @@ public override object Execute(object parameters) private object CreateObject(SceneParameters args) { + if (!string.IsNullOrWhiteSpace(args.primitiveType)) + return CreatePrimitive(args); + var name = string.IsNullOrWhiteSpace(args.name) ? "New Game Object" : args.name; var gameObject = new GameObject(name); Undo.RegisterCreatedObjectUndo(gameObject, "Create GameObject"); + FinalizeCreatedObject(gameObject, args); - if (!string.IsNullOrWhiteSpace(args.parentName)) + return CreateSuccessResponse(new { - var parent = GameObject.Find(args.parentName); - if (parent != null) - { - gameObject.transform.SetParent(parent.transform); - } + name = gameObject.name, + instanceId = UnityCompat.GetObjectId(gameObject), + scene = gameObject.scene.name, + components = gameObject.GetComponents().Select(c => c.GetType().Name).ToArray() + }, "GameObject created"); + } + + private object CreatePrimitive(SceneParameters args) + { + var primitiveName = !string.IsNullOrWhiteSpace(args.primitiveType) + ? args.primitiveType + : args.name; + + var primitive = McpEditorHelpers.ParsePrimitiveType(primitiveName); + if (primitive == null) + { + return CreateErrorResponse( + $"Invalid primitive type '{primitiveName}'. Supported: Cube, Sphere, Capsule, Cylinder, Plane, Quad."); } - ApplyTransform(gameObject.transform, args); - MarkSceneDirty(gameObject.scene); + var gameObject = GameObject.CreatePrimitive(primitive.Value); + gameObject.name = string.IsNullOrWhiteSpace(args.name) || args.name == primitiveName + ? primitiveName + : args.name; + + Undo.RegisterCreatedObjectUndo(gameObject, "Create Primitive"); + FinalizeCreatedObject(gameObject, args); return CreateSuccessResponse(new { name = gameObject.name, - instanceId = gameObject.GetEntityId(), // Use GetEntityId (GetInstanceID is obsolete) - scene = gameObject.scene.name - }, "GameObject created"); + primitive = primitive.Value.ToString(), + instanceId = UnityCompat.GetObjectId(gameObject), + scene = gameObject.scene.name, + components = gameObject.GetComponents().Select(c => c.GetType().Name).ToArray() + }, $"Primitive '{gameObject.name}' created"); } - private object DeleteObject(SceneParameters args) + private void FinalizeCreatedObject(GameObject gameObject, SceneParameters args) { - if (string.IsNullOrWhiteSpace(args.targetName)) + if (!string.IsNullOrWhiteSpace(args.parentName)) { - return CreateErrorResponse("Target name is required to delete an object."); + var parent = McpEditorHelpers.FindGameObject(args.parentName); + if (parent != null) + gameObject.transform.SetParent(parent.transform); } - var target = GameObject.Find(args.targetName); - if (target == null) + ApplyTransform(gameObject.transform, args); + + if (!string.IsNullOrWhiteSpace(args.tag)) { - return CreateErrorResponse($"GameObject '{args.targetName}' not found."); + try { gameObject.tag = args.tag; } + catch { LogError($"Tag '{args.tag}' is not defined."); } } - Undo.DestroyObjectImmediate(target); - MarkSceneDirty(target.scene); + if (args.layer >= 0 && args.layer < 32) + gameObject.layer = args.layer; - return CreateSuccessResponse(new + if (args.components != null) { - name = args.targetName - }, "GameObject deleted"); + foreach (var compType in args.components) + { + var type = McpEditorHelpers.FindComponentType(compType); + if (type != null) + Undo.AddComponent(gameObject, type); + } + } + + MarkSceneDirty(gameObject.scene); } - private object SetTransform(SceneParameters args) + private object DeleteObject(SceneParameters args) { - if (string.IsNullOrWhiteSpace(args.targetName)) - { - return CreateErrorResponse("Target name is required to set transform."); - } + var target = ResolveTarget(args); + if (target == null) + return CreateErrorResponse("GameObject not found. Provide gameObjectName or gameObjectId."); + + var targetName = target.name; + var scene = target.scene; + Undo.DestroyObjectImmediate(target); + MarkSceneDirty(scene); + + return CreateSuccessResponse(new { name = targetName }, "GameObject deleted"); + } - var target = GameObject.Find(args.targetName); + private object SetTransform(SceneParameters args) + { + var target = ResolveTarget(args); if (target == null) - { - return CreateErrorResponse($"GameObject '{args.targetName}' not found."); - } + return CreateErrorResponse("GameObject not found. Provide gameObjectName or gameObjectId."); Undo.RecordObject(target.transform, "Set Transform"); ApplyTransform(target.transform, args); @@ -118,61 +194,365 @@ private object SetTransform(SceneParameters args) return CreateSuccessResponse(new { name = target.name, - position = new { x = target.transform.position.x, y = target.transform.position.y, z = target.transform.position.z }, - rotation = new { x = target.transform.eulerAngles.x, y = target.transform.eulerAngles.y, z = target.transform.eulerAngles.z }, - scale = new { x = target.transform.localScale.x, y = target.transform.localScale.y, z = target.transform.localScale.z } + position = Vec(target.transform.position), + rotation = Vec(target.transform.eulerAngles), + scale = Vec(target.transform.localScale) }, "Transform updated"); } - private object ListRootObjects() + private object ModifyComponent(SceneParameters args, object rawParams) + { + var target = ResolveTarget(args); + if (target == null) + return CreateErrorResponse("GameObject not found."); + + if (string.IsNullOrWhiteSpace(args.componentType)) + return CreateErrorResponse("componentType is required."); + + var compAction = args.componentAction; + if (string.IsNullOrWhiteSpace(compAction) && rawParams is JObject jObj) + { + var nodeAction = jObj.Value("action"); + if (nodeAction is "add" or "remove" or "modify") + compAction = nodeAction; + } + compAction = (compAction ?? "add").Trim().ToLowerInvariant(); + + var type = McpEditorHelpers.FindComponentType(args.componentType); + if (type == null) + return CreateErrorResponse($"Component type '{args.componentType}' not found."); + + switch (compAction) + { + case "add": + { + if (target.GetComponent(type) != null) + return CreateErrorResponse($"Component '{args.componentType}' already exists on '{target.name}'."); + var added = Undo.AddComponent(target, type); + var addResult = ApplyComponentProperties(added, args.properties); + if (!addResult.Success) + return CreateErrorResponse("Component added but property assignment failed.", string.Join("; ", addResult.Errors)); + + MarkSceneDirty(target.scene); + return CreateSuccessResponse(new + { + gameObject = target.name, + component = added.GetType().Name, + propertiesApplied = addResult.Applied.ToArray() + }, "Component added"); + } + case "remove": + { + var existing = target.GetComponent(type); + if (existing == null) + return CreateErrorResponse($"Component '{args.componentType}' not found on '{target.name}'."); + Undo.DestroyObjectImmediate(existing); + MarkSceneDirty(target.scene); + return CreateSuccessResponse(new { gameObject = target.name, component = args.componentType }, "Component removed"); + } + case "modify": + { + var existing = target.GetComponent(type); + if (existing == null) + return CreateErrorResponse($"Component '{args.componentType}' not found on '{target.name}'."); + if (args.properties == null || !args.properties.HasValues) + return CreateErrorResponse("properties object is required for modify action."); + + var modifyResult = ApplyComponentProperties(existing, args.properties); + if (!modifyResult.Success) + return CreateErrorResponse("Component property modification failed.", string.Join("; ", modifyResult.Errors)); + + MarkSceneDirty(target.scene); + return CreateSuccessResponse(new + { + gameObject = target.name, + component = existing.GetType().Name, + propertiesApplied = modifyResult.Applied.ToArray() + }, "Component properties updated"); + } + default: + return CreateErrorResponse($"Unknown component action '{compAction}'. Use add, remove, or modify."); + } + } + + private object QueryScene(SceneParameters args) { var scene = EditorSceneManager.GetActiveScene(); var roots = scene.GetRootGameObjects(); - var rootInfo = new List(); + var results = new List(); foreach (var root in roots) { - rootInfo.Add(new - { - name = root.name, - instanceId = root.GetEntityId(), // Use GetEntityId (GetInstanceID is obsolete) - childCount = root.transform.childCount, - activeSelf = root.activeSelf - }); + if (!args.includeInactive && !root.activeInHierarchy) continue; + var node = BuildHierarchyNode(root.transform, 0, args); + if (node != null) results.Add(node); } return CreateSuccessResponse(new { scene = scene.name, - rootCount = roots.Length, - roots = rootInfo - }, "Root objects listed"); + scenePath = scene.path, + objectCount = results.Count, + objects = results + }, "Scene query completed"); + } + + private object BuildHierarchyNode(Transform transform, int depth, SceneParameters args) + { + if (args.maxDepth >= 0 && depth > args.maxDepth) return null; + + if (!string.IsNullOrWhiteSpace(args.filter) && + transform.name.IndexOf(args.filter, StringComparison.OrdinalIgnoreCase) < 0) + { + // Still search children even if parent doesn't match + var childMatches = new List(); + foreach (Transform child in transform) + { + var childNode = BuildHierarchyNode(child, depth + 1, args); + if (childNode != null) childMatches.Add(childNode); + } + if (childMatches.Count == 0) return null; + return new { name = transform.name, children = childMatches }; + } + + if (!args.includeInactive && !transform.gameObject.activeInHierarchy) + return null; + + var children = new List(); + foreach (Transform child in transform) + { + var childNode = BuildHierarchyNode(child, depth + 1, args); + if (childNode != null) children.Add(childNode); + } + + return new + { + name = transform.name, + instanceId = UnityCompat.GetObjectId(transform.gameObject), + active = transform.gameObject.activeSelf, + tag = transform.gameObject.tag, + layer = LayerMask.LayerToName(transform.gameObject.layer), + position = Vec(transform.position), + components = transform.GetComponents() + .Where(c => c != null) + .Select(c => c.GetType().Name) + .ToArray(), + children + }; + } + + private object SelectObjects(SceneParameters args) + { + var selected = new List(); + + if (args.objectNames != null) + { + foreach (var n in args.objectNames) + { + var go = McpEditorHelpers.FindGameObject(n); + if (go != null) selected.Add(go); + } + } + + if (args.instanceIds != null) + { + foreach (var id in args.instanceIds) + { + var go = McpEditorHelpers.FindGameObject(null, id); + if (go != null && !selected.Contains(go)) selected.Add(go); + } + } + + if (selected.Count == 0) + return CreateErrorResponse("No matching GameObjects found to select."); + + Selection.objects = selected.ToArray(); + if (selected.Count == 1) + Selection.activeGameObject = selected[0]; + + return CreateSuccessResponse(new + { + selectedCount = selected.Count, + names = selected.Select(g => g.name).ToArray() + }, $"Selected {selected.Count} object(s)"); + } + + private object SaveScene(SceneParameters args) + { + var scene = EditorSceneManager.GetActiveScene(); + if (!scene.IsValid()) + return CreateErrorResponse("No active scene to save."); + + string savePath = scene.path; + if (!string.IsNullOrWhiteSpace(args.path)) + { + savePath = McpEditorHelpers.NormalizeAssetPath(args.path); + if (savePath == null) + return CreateErrorResponse("path must be inside the Assets folder."); + if (!savePath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase)) + savePath += ".unity"; + McpEditorHelpers.EnsureAssetFolder(System.IO.Path.GetDirectoryName(savePath)?.Replace("\\", "/")); + } + + if (string.IsNullOrEmpty(savePath)) + return CreateErrorResponse("Scene has no path. Provide a path to save a new scene."); + + var saved = EditorSceneManager.SaveScene(scene, savePath); + if (!saved) + return CreateErrorResponse($"Failed to save scene to {savePath}"); + + return CreateSuccessResponse(new { path = savePath, scene = scene.name }, "Scene saved"); + } + + private object LoadScene(SceneParameters args) + { + if (string.IsNullOrWhiteSpace(args.scenePath)) + return CreateErrorResponse("scenePath is required."); + + var path = McpEditorHelpers.NormalizeAssetPath(args.scenePath); + if (path == null) + return CreateErrorResponse("scenePath must be inside the Assets folder."); + if (!path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase)) + path += ".unity"; + + if (!System.IO.File.Exists(path)) + return CreateErrorResponse($"Scene file not found: {path}"); + + var mode = args.additive ? OpenSceneMode.Additive : OpenSceneMode.Single; + var scene = EditorSceneManager.OpenScene(path, mode); + + return CreateSuccessResponse(new + { + name = scene.name, + path = scene.path, + additive = args.additive, + loadedScenes = UnityEngine.SceneManagement.SceneManager.loadedSceneCount + }, $"Scene '{scene.name}' loaded"); + } + + private GameObject ResolveTarget(SceneParameters args) + { + var id = args.gameObjectId != 0 ? args.gameObjectId : args.instanceId; + var name = !string.IsNullOrWhiteSpace(args.gameObjectName) + ? args.gameObjectName + : args.targetName; + return McpEditorHelpers.FindGameObject(name, id); } private void ApplyTransform(Transform target, SceneParameters args) { if (args.position?.Length == 3) { - target.position = new Vector3(args.position[0], args.position[1], args.position[2]); + var pos = new Vector3(args.position[0], args.position[1], args.position[2]); + target.position = args.relative ? target.position + pos : pos; } if (args.rotation?.Length == 3) { - target.eulerAngles = new Vector3(args.rotation[0], args.rotation[1], args.rotation[2]); + var rot = new Vector3(args.rotation[0], args.rotation[1], args.rotation[2]); + target.eulerAngles = args.relative ? target.eulerAngles + rot : rot; } if (args.scale?.Length == 3) { - target.localScale = new Vector3(args.scale[0], args.scale[1], args.scale[2]); + var scl = new Vector3(args.scale[0], args.scale[1], args.scale[2]); + target.localScale = args.relative + ? Vector3.Scale(target.localScale, scl) + : scl; } } private void MarkSceneDirty(UnityEngine.SceneManagement.Scene scene) { if (scene.IsValid()) - { EditorSceneManager.MarkSceneDirty(scene); + } + + private static McpEditorHelpers.ApplyPropertiesResult ApplyComponentProperties(Component component, JObject properties) + { + return McpEditorHelpers.ApplySerializedProperties(component, properties); + } + + private static object Vec(Vector3 v) => new { x = v.x, y = v.y, z = v.z }; + + private SceneParameters NormalizeParameters(object parameters) + { + var args = GetParameters(parameters); + + JObject source = null; + if (parameters is JObject jObject) source = jObject; + else if (parameters is Dictionary dict) source = JObject.FromObject(dict); + + if (source != null) + { + if (string.IsNullOrWhiteSpace(args.parentName)) + args.parentName = source.Value("parent") ?? args.parentName; + + if (string.IsNullOrWhiteSpace(args.targetName)) + args.targetName = source.Value("gameObjectName") + ?? source.Value("targetName") + ?? ""; + + if (string.IsNullOrWhiteSpace(args.name) || args.name == "New Game Object") + { + var explicitName = source.Value("name"); + if (!string.IsNullOrWhiteSpace(explicitName) && args.action == "create_object") + args.name = explicitName; + } + + if (args.gameObjectId == 0) + args.gameObjectId = source.Value("gameObjectId") ?? 0; + + if (args.instanceId == 0) + args.instanceId = source.Value("instanceId") ?? 0; + + if (args.position == null || args.position.Length != 3) + args.position = VectorToArray(McpEditorHelpers.ExtractVector3(source["position"])); + + if (args.rotation == null || args.rotation.Length != 3) + args.rotation = VectorToArray(McpEditorHelpers.ExtractVector3(source["rotation"])); + + if (args.scale == null || args.scale.Length != 3) + args.scale = VectorToArray(McpEditorHelpers.ExtractVector3(source["scale"])); + + if (source["components"] is JArray compArr) + args.components = compArr.Select(t => t.ToString()).ToArray(); + + if (source["objectNames"] is JArray namesArr) + args.objectNames = namesArr.Select(t => t.ToString()).ToArray(); + + if (source["instanceIds"] is JArray idsArr) + args.instanceIds = idsArr.Select(t => t.Value()).ToArray(); + + args.componentAction = source.Value("action"); + if (args.componentAction is "add" or "remove" or "modify") + { + // This is the component sub-action, not the scene action — already set via InjectAction on outer action + } + + args.scenePath = source.Value("scenePath") ?? args.scenePath; + args.path = source.Value("path") ?? args.path; + args.filter = source.Value("filter") ?? args.filter; + args.includeInactive = source.Value("includeInactive") ?? args.includeInactive; + args.maxDepth = source.Value("maxDepth") ?? args.maxDepth; + args.relative = source.Value("relative") ?? args.relative; + args.additive = source.Value("additive") ?? args.additive; + args.componentType = source.Value("componentType") ?? args.componentType; + args.primitiveType = source.Value("primitive") + ?? source.Value("primitiveType") + ?? args.primitiveType; + + if (source["properties"] is JObject propsObj) + args.properties = propsObj; } + + return args; + } + + private static float[] VectorToArray(Vector3? v) + { + if (!v.HasValue) return null; + return new[] { v.Value.x, v.Value.y, v.Value.z }; } } -} +} \ No newline at end of file diff --git a/Unity/Editor/UnityCompat.cs b/Unity/Editor/UnityCompat.cs new file mode 100644 index 0000000..a071acb --- /dev/null +++ b/Unity/Editor/UnityCompat.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; +using UnityEngine; + +namespace UnityMCP.Editor +{ + /// + /// Cross-version helpers for Unity API differences (e.g. Unity 6 entity IDs and physics naming). + /// + internal static class UnityCompat + { + public static int GetObjectId(UnityEngine.Object obj) + { + if (obj == null) return 0; + + // Editor MCP uses instance IDs for cross-request object references. + // Unity 6's GetEntityId() returns an EntityId struct (not castable to int). +#pragma warning disable CS0618 + return obj.GetInstanceID(); +#pragma warning restore CS0618 + } + + public static Vector3 GetRigidbodyVelocity(Rigidbody rb) + { + if (rb == null) return Vector3.zero; + + var prop = rb.GetType().GetProperty("linearVelocity", BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.PropertyType == typeof(Vector3)) + { + return (Vector3)prop.GetValue(rb); + } + + return rb.velocity; + } + + public static Vector3 GetRigidbodyAngularVelocity(Rigidbody rb) + { + return rb != null ? rb.angularVelocity : Vector3.zero; + } + + public static float GetRigidbodyDrag(Rigidbody rb) + { + if (rb == null) return 0f; + + var prop = rb.GetType().GetProperty("linearDamping", BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.PropertyType == typeof(float)) + { + return (float)prop.GetValue(rb); + } + + return rb.drag; + } + + public static float GetRigidbodyAngularDrag(Rigidbody rb) + { + return rb != null ? rb.angularDrag : 0f; + } + + public static Vector2 GetRigidbody2DVelocity(Rigidbody2D rb2d) + { + if (rb2d == null) return Vector2.zero; + + var prop = rb2d.GetType().GetProperty("linearVelocity", BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.PropertyType == typeof(Vector2)) + { + return (Vector2)prop.GetValue(rb2d); + } + + return rb2d.velocity; + } + + public static bool TryCopyAsset(string sourcePath, string destinationPath, out string error) + { + error = null; +#if UNITY_6000_0_OR_NEWER + if (!UnityEditor.AssetDatabase.CopyAsset(sourcePath, destinationPath)) + { + error = $"Failed to copy asset from {sourcePath} to {destinationPath}"; + return false; + } + return true; +#else + error = UnityEditor.AssetDatabase.CopyAsset(sourcePath, destinationPath); + return string.IsNullOrEmpty(error); +#endif + } + } +} \ No newline at end of file diff --git a/Unity/Editor/UnityCompat.cs.meta b/Unity/Editor/UnityCompat.cs.meta new file mode 100644 index 0000000..34284e7 --- /dev/null +++ b/Unity/Editor/UnityCompat.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4e8b1d6f3a2c9075b6e4d1f8a3c59270 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/test-final.js b/test-final.js index d129d97..720a1d7 100644 --- a/test-final.js +++ b/test-final.js @@ -2,10 +2,10 @@ const http = require('http'); // Test the project.analyze tool after the threading fix const testData = JSON.stringify({ - Id: 'final-test', - Type: 'request', - Method: 'project.analyze', - Params: { + id: 'final-test', + type: 'request', + method: 'project.analyze', + params: { includeAssets: false, includePackages: true, includeScenes: true, @@ -33,19 +33,20 @@ const req = http.request(options, (res) => { console.log('\n📋 Unity Response:'); try { const parsed = JSON.parse(response); - if (parsed.Error) { - console.log('❌ Error:', parsed.Error.Message); - } else if (parsed.Result) { + if (parsed.error) { + console.log('❌ Error:', parsed.error.message); + } else if (parsed.result) { console.log('✅ SUCCESS! Unity tools working properly'); console.log('📊 Project Info:'); - if (parsed.Result.project) { - console.log(` - Project: ${parsed.Result.project.projectName}`); - console.log(` - Unity Version: ${parsed.Result.project.unityVersion}`); - console.log(` - Platform: ${parsed.Result.project.platform}`); + const data = parsed.result.data ?? parsed.result; + if (data.project) { + console.log(` - Project: ${data.project.projectName}`); + console.log(` - Unity Version: ${data.project.unityVersion}`); + console.log(` - Platform: ${data.project.platform}`); } - if (parsed.Result.scenes) { - console.log(` - Total Scenes: ${parsed.Result.scenes.totalScenes}`); - console.log(` - Active Scene: ${parsed.Result.scenes.activeScene}`); + if (data.scenes) { + console.log(` - Total Scenes: ${data.scenes.totalScenes}`); + console.log(` - Active Scene: ${data.scenes.activeScene}`); } console.log('\n🎉 MCP Integration is working correctly!'); } else { diff --git a/test-scene-building.js b/test-scene-building.js new file mode 100644 index 0000000..7f1604f --- /dev/null +++ b/test-scene-building.js @@ -0,0 +1,106 @@ +const http = require('http'); + +function sendRequest(method, params) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + id: `test-${Date.now()}`, + type: 'request', + method, + params, + }); + + const req = http.request( + { + hostname: 'localhost', + port: 8090, + path: '/', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: 30000, + }, + (res) => { + let response = ''; + res.on('data', (chunk) => (response += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(response)); + } catch (e) { + reject(new Error(`Invalid JSON: ${response.slice(0, 200)}`)); + } + }); + } + ); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + req.write(body); + req.end(); + }); +} + +async function run() { + console.log('Scene-building smoke test (Unity must be running on :8090)\n'); + + const ping = await sendRequest('test', {}); + console.log('✅ ping:', ping.result?.status ?? ping.result); + + function payload(result) { + if (!result || result.error) throw new Error(result?.error?.message ?? 'request failed'); + if (result.success === false) throw new Error(result.message ?? 'tool failed'); + return result.data ?? result; + } + + const slope = await sendRequest('scene.createGameObject', { + name: 'MCP_Slope', + primitive: 'Plane', + scale: { x: 10, y: 1, z: 20 }, + }); + const slopeData = payload(slope.result); + console.log('✅ createGameObject (primitive):', slopeData?.name, 'id=', slopeData?.instanceId); + + const skier = await sendRequest('scene.createPrimitive', { + name: 'MCP_Skier', + primitiveType: 'Capsule', + position: { x: 0, y: 2, z: 0 }, + }); + const skierData = payload(skier.result); + console.log('✅ createPrimitive:', skierData?.name, 'id=', skierData?.instanceId); + + const addRb = await sendRequest('scene.modifyComponent', { + gameObjectName: 'MCP_Skier', + action: 'add', + componentType: 'Rigidbody', + }); + console.log('✅ add Rigidbody:', payload(addRb.result).message ?? addRb.result?.message); + + const modRb = await sendRequest('scene.modifyComponent', { + gameObjectName: 'MCP_Skier', + action: 'modify', + componentType: 'Rigidbody', + properties: { mass: 70, useGravity: true }, + }); + console.log('✅ modify Rigidbody:', payload(modRb.result).message ?? modRb.result?.message); + + const query = await sendRequest('scene.query', { filter: 'MCP_' }); + const queryData = payload(query.result); + const objects = queryData?.objects ?? []; + console.log('✅ query:', objects.length, 'root(s) matching MCP_'); + + if (typeof skierData?.instanceId !== 'number') { + throw new Error(`instanceId should be number, got ${JSON.stringify(skierData?.instanceId)}`); + } + + console.log('\n🎉 Scene-building smoke test passed'); +} + +run().catch((err) => { + console.error('❌', err.message); + console.error('Start Unity → Tools > Unity MCP > Server Window → Start Server'); + process.exit(1); +}); \ No newline at end of file