@akiojin/unity-mcp-server 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "MCP server and Unity Editor bridge — enables AI assistants to control Unity for AI-assisted workflows",
5
5
  "type": "module",
6
6
  "main": "src/core/server.js",
@@ -46,6 +46,22 @@ export class ProjectInfoProvider {
46
46
 
47
47
  async get() {
48
48
  if (this.cached) return this.cached;
49
+ // Env-driven project root override (primarily for tests)
50
+ const envRootRaw = process.env.UNITY_PROJECT_ROOT;
51
+ if (typeof envRootRaw === 'string' && envRootRaw.trim().length > 0) {
52
+ const envRoot = envRootRaw.trim();
53
+ const projectRoot = normalize(path.resolve(envRoot));
54
+ const codeIndexRoot = normalize(resolveDefaultCodeIndexRoot(projectRoot));
55
+ this.cached = {
56
+ projectRoot,
57
+ assetsPath: normalize(path.join(projectRoot, 'Assets')),
58
+ packagesPath: normalize(path.join(projectRoot, 'Packages')),
59
+ packageCachePath: normalize(path.join(projectRoot, 'Library/PackageCache')),
60
+ codeIndexRoot
61
+ };
62
+ return this.cached;
63
+ }
64
+
49
65
  // Config-driven project root (no env fallback)
50
66
  const cfgRootRaw = config?.project?.root;
51
67
  if (typeof cfgRootRaw === 'string' && cfgRootRaw.trim().length > 0) {
@@ -61,6 +77,7 @@ export class ProjectInfoProvider {
61
77
  projectRoot,
62
78
  assetsPath: normalize(path.join(projectRoot, 'Assets')),
63
79
  packagesPath: normalize(path.join(projectRoot, 'Packages')),
80
+ packageCachePath: normalize(path.join(projectRoot, 'Library/PackageCache')),
64
81
  codeIndexRoot
65
82
  };
66
83
  return this.cached;
@@ -76,6 +93,9 @@ export class ProjectInfoProvider {
76
93
  projectRoot: info.projectRoot,
77
94
  assetsPath: info.assetsPath,
78
95
  packagesPath: normalize(info.packagesPath || path.join(info.projectRoot, 'Packages')),
96
+ packageCachePath: normalize(
97
+ info.packageCachePath || path.join(info.projectRoot, 'Library/PackageCache')
98
+ ),
79
99
  codeIndexRoot: normalize(
80
100
  info.codeIndexRoot || resolveDefaultCodeIndexRoot(info.projectRoot)
81
101
  )
@@ -96,6 +116,7 @@ export class ProjectInfoProvider {
96
116
  projectRoot,
97
117
  assetsPath: normalize(path.join(projectRoot, 'Assets')),
98
118
  packagesPath: normalize(path.join(projectRoot, 'Packages')),
119
+ packageCachePath: normalize(path.join(projectRoot, 'Library/PackageCache')),
99
120
  codeIndexRoot
100
121
  };
101
122
  return this.cached;
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { parentPort, workerData } from 'worker_threads';
18
- import { spawn } from 'child_process';
18
+ import { spawn, execFile } from 'child_process';
19
19
  import fs from 'fs';
20
20
  import path from 'path';
21
21
  import os from 'os';
@@ -107,6 +107,111 @@ function walkCs(root, files, seen) {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Fast file enumeration using OS native commands (find on Unix, PowerShell on Windows)
112
+ * Falls back to walkCs on failure
113
+ * @param {string[]} roots - Array of root directories to scan
114
+ * @returns {Promise<string[]>} Array of absolute C# file paths
115
+ */
116
+ async function fastWalkCs(roots) {
117
+ const isWindows = process.platform === 'win32';
118
+ const allFiles = [];
119
+ const seen = new Set();
120
+
121
+ for (const root of roots) {
122
+ if (!fs.existsSync(root)) {
123
+ log('info', `[worker] Skipping non-existent root: ${root}`);
124
+ continue;
125
+ }
126
+
127
+ log('info', `[worker] Scanning ${path.basename(root)}...`);
128
+ const startTime = Date.now();
129
+
130
+ try {
131
+ const files = await new Promise((resolve, reject) => {
132
+ if (isWindows) {
133
+ // Windows: Use PowerShell (secure - no shell interpretation)
134
+ execFile(
135
+ 'powershell',
136
+ [
137
+ '-NoProfile',
138
+ '-Command',
139
+ `Get-ChildItem -Path '${root.replace(/'/g, "''")}' -Recurse -Include *.cs -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName`
140
+ ],
141
+ { maxBuffer: 50 * 1024 * 1024 }, // 50MB buffer for large projects
142
+ (error, stdout) => {
143
+ if (error) {
144
+ reject(error);
145
+ return;
146
+ }
147
+ const files = stdout
148
+ .split('\r\n')
149
+ .map(f => f.trim())
150
+ .filter(f => f && f.endsWith('.cs'));
151
+ resolve(files);
152
+ }
153
+ );
154
+ } else {
155
+ // Unix: Use find command (secure - no shell interpretation)
156
+ // Note: We intentionally don't exclude hidden directories (*/.*) because:
157
+ // - The search root may be inside .worktrees which contains a dot
158
+ // - -name '*.cs' already filters to only C# files (excluding .meta etc.)
159
+ execFile(
160
+ 'find',
161
+ [
162
+ root,
163
+ '-name',
164
+ '*.cs',
165
+ '-type',
166
+ 'f',
167
+ '-not',
168
+ '-path',
169
+ '*/obj/*',
170
+ '-not',
171
+ '-path',
172
+ '*/bin/*'
173
+ ],
174
+ { maxBuffer: 50 * 1024 * 1024 }, // 50MB buffer for large projects
175
+ (error, stdout) => {
176
+ if (error) {
177
+ reject(error);
178
+ return;
179
+ }
180
+ const files = stdout
181
+ .split('\n')
182
+ .map(f => f.trim())
183
+ .filter(f => f && f.endsWith('.cs'));
184
+ resolve(files);
185
+ }
186
+ );
187
+ }
188
+ });
189
+
190
+ const elapsed = Date.now() - startTime;
191
+ log('info', `[worker] Found ${files.length} files in ${path.basename(root)} (${elapsed}ms)`);
192
+
193
+ // Add unique files
194
+ for (const f of files) {
195
+ if (!seen.has(f)) {
196
+ seen.add(f);
197
+ allFiles.push(f);
198
+ }
199
+ }
200
+ } catch (e) {
201
+ // Fallback to walkCs on error
202
+ log(
203
+ 'warn',
204
+ `[worker] Fast scan failed for ${path.basename(root)}: ${e.message}. Using fallback.`
205
+ );
206
+ const fallbackFiles = [];
207
+ walkCs(root, fallbackFiles, seen);
208
+ allFiles.push(...fallbackFiles);
209
+ }
210
+ }
211
+
212
+ return allFiles;
213
+ }
214
+
110
215
  /**
111
216
  * Convert absolute path to relative path
112
217
  */
@@ -406,7 +511,9 @@ async function runBuild() {
406
511
  concurrency: _concurrency = 1, // Reserved for future parallel processing
407
512
  throttleMs = 0,
408
513
  retry = 2,
409
- reportPercentage = 10
514
+ reportPercentage = 10,
515
+ excludePackageCache = false,
516
+ forceRebuild = false // Skip change detection and rebuild all files
410
517
  } = workerData;
411
518
  void _concurrency; // Explicitly mark as intentionally unused
412
519
 
@@ -473,36 +580,72 @@ async function runBuild() {
473
580
  runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path)');
474
581
 
475
582
  // Scan for C# files
476
- const roots = [
477
- path.resolve(projectRoot, 'Assets'),
478
- path.resolve(projectRoot, 'Packages'),
479
- path.resolve(projectRoot, 'Library/PackageCache')
480
- ];
481
- const files = [];
482
- const seen = new Set();
483
- for (const r of roots) walkCs(r, files, seen);
583
+ // Build roots list based on excludePackageCache option
584
+ const roots = [path.resolve(projectRoot, 'Assets'), path.resolve(projectRoot, 'Packages')];
585
+ if (!excludePackageCache) {
586
+ roots.push(path.resolve(projectRoot, 'Library/PackageCache'));
587
+ }
588
+ log(
589
+ 'info',
590
+ `[worker] Scanning roots: ${roots.map(r => path.basename(r)).join(', ')}${excludePackageCache ? ' (PackageCache excluded)' : ''}`
591
+ );
484
592
 
593
+ // Use fast find command when available, fallback to walkCs
594
+ const files = await fastWalkCs(roots);
485
595
  log('info', `[worker] Found ${files.length} C# files to process`);
486
596
 
487
- // Get current indexed files
488
- const currentResult = querySQL(db, 'SELECT path, sig FROM files');
489
- const current = new Map();
490
- if (currentResult.length > 0) {
491
- for (const row of currentResult[0].values) {
492
- current.set(row[0], row[1]);
597
+ let changed = [];
598
+ let removed = [];
599
+ const wanted = new Map();
600
+
601
+ if (forceRebuild) {
602
+ // Skip change detection - process all files
603
+ log('info', `[worker] forceRebuild=true, skipping change detection`);
604
+ // Clear all existing data
605
+ runSQL(db, 'DELETE FROM symbols');
606
+ runSQL(db, 'DELETE FROM files');
607
+ // Mark all files as changed
608
+ for (const abs of files) {
609
+ const rel = toRel(abs, projectRoot);
610
+ wanted.set(rel, '0-0'); // Dummy signature for forceRebuild
611
+ changed.push(rel);
612
+ }
613
+ log('info', `[worker] All ${changed.length} files marked for processing`);
614
+ } else {
615
+ // Normal change detection
616
+ // Get current indexed files
617
+ const currentResult = querySQL(db, 'SELECT path, sig FROM files');
618
+ const current = new Map();
619
+ if (currentResult.length > 0) {
620
+ for (const row of currentResult[0].values) {
621
+ current.set(row[0], row[1]);
622
+ }
493
623
  }
494
- }
495
624
 
496
- // Determine changes
497
- const wanted = new Map(files.map(abs => [toRel(abs, projectRoot), makeSig(abs)]));
498
- const changed = [];
499
- const removed = [];
625
+ // Determine changes (this calls makeSig for each file)
626
+ log('info', `[worker] Computing file signatures (${files.length} files)...`);
627
+ const sigStartTime = Date.now();
628
+ let sigProcessed = 0;
629
+ for (const abs of files) {
630
+ const rel = toRel(abs, projectRoot);
631
+ const sig = makeSig(abs);
632
+ wanted.set(rel, sig);
633
+ sigProcessed++;
634
+ // Report progress every 10000 files
635
+ if (sigProcessed % 10000 === 0) {
636
+ const elapsed = ((Date.now() - sigStartTime) / 1000).toFixed(1);
637
+ log('info', `[worker] Signature progress: ${sigProcessed}/${files.length} (${elapsed}s)`);
638
+ }
639
+ }
640
+ const sigTime = ((Date.now() - sigStartTime) / 1000).toFixed(1);
641
+ log('info', `[worker] Signatures computed in ${sigTime}s`);
500
642
 
501
- for (const [rel, sig] of wanted) {
502
- if (current.get(rel) !== sig) changed.push(rel);
503
- }
504
- for (const [rel] of current) {
505
- if (!wanted.has(rel)) removed.push(rel);
643
+ for (const [rel, sig] of wanted) {
644
+ if (current.get(rel) !== sig) changed.push(rel);
645
+ }
646
+ for (const [rel] of current) {
647
+ if (!wanted.has(rel)) removed.push(rel);
648
+ }
506
649
  }
507
650
 
508
651
  log('info', `[worker] Changes: ${changed.length} to update, ${removed.length} to remove`);
@@ -64,17 +64,16 @@ export class SceneCreateToolHandler extends BaseToolHandler {
64
64
  // Send command to Unity
65
65
  const result = await this.unityConnection.sendCommand('create_scene', params);
66
66
 
67
- // Check for Unity-side errors
68
- if (result.status === 'error') {
67
+ // Unity returns errors as { error: "..." } payloads (still wrapped in a success response)
68
+ if (result && result.error) {
69
69
  const error = new Error(result.error);
70
70
  error.code = 'UNITY_ERROR';
71
71
  throw error;
72
72
  }
73
73
 
74
- // Handle undefined or null results from Unity
75
- if (result.result === undefined || result.result === null) {
74
+ // Handle undefined or null results from Unity (best-effort)
75
+ if (result === undefined || result === null) {
76
76
  return {
77
- status: 'success',
78
77
  sceneName: params.sceneName,
79
78
  path: params.path || 'Assets/Scenes/',
80
79
  loadScene: params.loadScene !== false,
@@ -83,6 +82,6 @@ export class SceneCreateToolHandler extends BaseToolHandler {
83
82
  };
84
83
  }
85
84
 
86
- return result.result;
85
+ return result;
87
86
  }
88
87
  }
@@ -67,17 +67,16 @@ export class SceneLoadToolHandler extends BaseToolHandler {
67
67
  // Send command to Unity
68
68
  const result = await this.unityConnection.sendCommand('load_scene', params);
69
69
 
70
- // Check for Unity-side errors
71
- if (result.status === 'error') {
70
+ // Unity returns errors as { error: "..." } payloads (still wrapped in a success response)
71
+ if (result && result.error) {
72
72
  const error = new Error(result.error);
73
73
  error.code = 'UNITY_ERROR';
74
74
  throw error;
75
75
  }
76
76
 
77
- // Handle undefined or null results from Unity
78
- if (result.result === undefined || result.result === null) {
77
+ // Handle undefined or null results from Unity (best-effort)
78
+ if (result === undefined || result === null) {
79
79
  return {
80
- status: 'success',
81
80
  sceneName: params.sceneName || 'Unknown',
82
81
  scenePath: params.scenePath || 'Unknown',
83
82
  loadMode: params.loadMode || 'Single',
@@ -85,6 +84,6 @@ export class SceneLoadToolHandler extends BaseToolHandler {
85
84
  };
86
85
  }
87
86
 
88
- return result.result;
87
+ return result;
89
88
  }
90
89
  }
@@ -51,23 +51,22 @@ export class SceneSaveToolHandler extends BaseToolHandler {
51
51
  // Send command to Unity
52
52
  const result = await this.unityConnection.sendCommand('save_scene', params);
53
53
 
54
- // Check for Unity-side errors
55
- if (result.status === 'error') {
54
+ // Unity returns errors as { error: "..." } payloads (still wrapped in a success response)
55
+ if (result && result.error) {
56
56
  const error = new Error(result.error);
57
57
  error.code = 'UNITY_ERROR';
58
58
  throw error;
59
59
  }
60
60
 
61
- // Handle undefined or null results from Unity
62
- if (result.result === undefined || result.result === null) {
61
+ // Handle undefined or null results from Unity (best-effort)
62
+ if (result === undefined || result === null) {
63
63
  return {
64
- status: 'success',
65
64
  scenePath: params.scenePath || 'Current scene path',
66
65
  saveAs: params.saveAs === true,
67
66
  message: 'Scene save completed but Unity returned no details'
68
67
  };
69
68
  }
70
69
 
71
- return result.result;
70
+ return result;
72
71
  }
73
72
  }
@@ -23,6 +23,11 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
23
23
  minimum: 0,
24
24
  maximum: 5,
25
25
  description: 'Number of retries for LSP requests (default: 2).'
26
+ },
27
+ excludePackageCache: {
28
+ type: 'boolean',
29
+ description:
30
+ 'Exclude Library/PackageCache from indexing (default: false). Set to true for faster builds when package symbols are not needed.'
26
31
  }
27
32
  },
28
33
  required: []
@@ -54,7 +59,8 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
54
59
  return {
55
60
  success: false,
56
61
  error: 'build_already_running',
57
- message: 'Code index build is already running (Worker Thread). Use code_index_status to check progress.',
62
+ message:
63
+ 'Code index build is already running (Worker Thread). Use code_index_status to check progress.',
58
64
  jobId: this.currentJobId
59
65
  };
60
66
  }
@@ -86,7 +92,8 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
86
92
  concurrency: 1, // Worker Thread uses sequential processing for stability
87
93
  throttleMs: Math.max(0, Number(params?.throttleMs ?? 0)),
88
94
  retry: Math.max(0, Math.min(5, Number(params?.retry ?? 2))),
89
- reportPercentage: 10
95
+ reportPercentage: 10,
96
+ excludePackageCache: Boolean(params?.excludePackageCache)
90
97
  });
91
98
 
92
99
  // Clear current job tracking on success
@@ -8,7 +8,7 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
8
8
  constructor(unityConnection) {
9
9
  super(
10
10
  'script_search',
11
- '[OFFLINE] No Unity connection required. Search C# by substring/regex/glob with pagination and snippet context. PRIORITY: Use to locate symbols/files; avoid full contents. Use returnMode="snippets" (or "metadata") with small snippetContext (1–2). Narrow aggressively via include globs under Assets/** or Packages/** and semantic filters (namespace/container/identifier). Do NOT prefix repository folders.',
11
+ '[OFFLINE] No Unity connection required. Search C# by substring/regex/glob with pagination and snippet context. PRIORITY: Use to locate symbols/files; avoid full contents. Use returnMode="snippets" (or "metadata") with small snippetContext (1–2). Narrow aggressively via include globs under Assets/**, Packages/**, or Library/PackageCache/** and semantic filters (namespace/container/identifier). Do NOT prefix repository folders.',
12
12
  {
13
13
  type: 'object',
14
14
  properties: {
@@ -29,9 +29,9 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
29
29
  },
30
30
  scope: {
31
31
  type: 'string',
32
- enum: ['assets', 'packages', 'embedded', 'all'],
32
+ enum: ['assets', 'packages', 'embedded', 'library', 'all'],
33
33
  description:
34
- 'Search scope: assets (Assets/, default), packages (Packages/), embedded, or all.'
34
+ 'Search scope: assets (Assets/), packages (Packages/), embedded, library (Library/PackageCache/), or all. Default: all.'
35
35
  },
36
36
  include: {
37
37
  type: 'string',
@@ -151,6 +151,7 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
151
151
  if (scope === 'assets' || scope === 'all') roots.push(info.assetsPath);
152
152
  if (scope === 'packages' || scope === 'embedded' || scope === 'all')
153
153
  roots.push(info.packagesPath);
154
+ if (scope === 'library' || scope === 'all') roots.push(info.packageCachePath);
154
155
 
155
156
  const includeRx = globToRegExp(include);
156
157
  const excludeRx = exclude ? globToRegExp(exclude) : null;
@@ -24,6 +24,10 @@ export class UIFindElementsToolHandler extends BaseToolHandler {
24
24
  canvasFilter: {
25
25
  type: 'string',
26
26
  description: 'Filter by parent Canvas name.'
27
+ },
28
+ uiSystem: {
29
+ type: 'string',
30
+ description: 'Filter by UI system: ugui|uitk|imgui|all (default: all).'
27
31
  }
28
32
  },
29
33
  required: []
@@ -32,7 +36,14 @@ export class UIFindElementsToolHandler extends BaseToolHandler {
32
36
  }
33
37
 
34
38
  async execute(params = {}) {
35
- const { elementType, tagFilter, namePattern, includeInactive = false, canvasFilter } = params;
39
+ const {
40
+ elementType,
41
+ tagFilter,
42
+ namePattern,
43
+ includeInactive = false,
44
+ canvasFilter,
45
+ uiSystem
46
+ } = params;
36
47
 
37
48
  // Ensure connected
38
49
  if (!this.unityConnection.isConnected()) {
@@ -44,7 +55,8 @@ export class UIFindElementsToolHandler extends BaseToolHandler {
44
55
  tagFilter,
45
56
  namePattern,
46
57
  includeInactive,
47
- canvasFilter
58
+ canvasFilter,
59
+ uiSystem
48
60
  });
49
61
 
50
62
  // Return the result for the BaseToolHandler to format
@@ -57,10 +57,10 @@ export async function getSceneInfoHandler(unityConnection, args) {
57
57
  }
58
58
 
59
59
  // Send command to Unity
60
- const result = await unityConnection.sendCommand('scene_info_get', args);
60
+ const result = await unityConnection.sendCommand('get_scene_info', args);
61
61
 
62
- // Handle Unity response
63
- if (result.status === 'error') {
62
+ // Unity returns errors as { error: "..." } payloads (still wrapped in a success response)
63
+ if (result && result.error) {
64
64
  return {
65
65
  content: [
66
66
  {
@@ -77,7 +77,7 @@ export async function getSceneInfoHandler(unityConnection, args) {
77
77
  content: [
78
78
  {
79
79
  type: 'text',
80
- text: result.result.summary || `Scene information retrieved`
80
+ text: result?.summary || `Scene information retrieved`
81
81
  }
82
82
  ],
83
83
  isError: false