@akiojin/unity-mcp-server 3.0.0 → 3.2.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.
@@ -34,7 +34,7 @@ const args = process.argv.slice(2);
34
34
  const command = args[0] && !args[0].startsWith('--') ? args[0] : null;
35
35
  const rest = command ? args.slice(1) : args;
36
36
 
37
- let httpEnabled = false;
37
+ let httpEnabled;
38
38
  let httpPort;
39
39
  let stdioEnabled = true;
40
40
  let telemetryEnabled;
@@ -114,11 +114,12 @@ async function main() {
114
114
 
115
115
  // Start MCP server (dynamic import)
116
116
  const { startServer } = await import('../src/core/server.js');
117
+ const http = {};
118
+ if (httpEnabled !== undefined) http.enabled = httpEnabled;
119
+ if (httpPort !== undefined) http.port = httpPort;
120
+
117
121
  await startServer({
118
- http: {
119
- enabled: httpEnabled,
120
- port: httpPort
121
- },
122
+ http: Object.keys(http).length > 0 ? http : undefined,
122
123
  telemetry: telemetryEnabled === undefined ? undefined : { enabled: telemetryEnabled },
123
124
  stdioEnabled
124
125
  });
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.2.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",
@@ -1,12 +1,8 @@
1
1
  import fs from 'fs';
2
- import os from 'os';
3
2
  import path from 'path';
4
3
  import * as findUpPkg from 'find-up';
5
4
  import { MCPLogger } from './mcpLogger.js';
6
5
 
7
- // Diagnostic log: confirm module loading reached this point
8
- process.stderr.write('[unity-mcp-server] Config module loading...\n');
9
-
10
6
  function findUpSyncCompat(matcher, options = {}) {
11
7
  if (typeof matcher === 'function') {
12
8
  let dir = options.cwd || process.cwd();
@@ -39,6 +35,30 @@ function merge(a, b) {
39
35
  return out;
40
36
  }
41
37
 
38
+ function parseBoolEnv(value) {
39
+ if (typeof value !== 'string') return undefined;
40
+ const v = value.trim().toLowerCase();
41
+ if (v === '') return undefined;
42
+ if (v === '1' || v === 'true' || v === 'yes' || v === 'y' || v === 'on') return true;
43
+ if (v === '0' || v === 'false' || v === 'no' || v === 'n' || v === 'off') return false;
44
+ return undefined;
45
+ }
46
+
47
+ function parseIntEnv(value) {
48
+ if (typeof value !== 'string') return undefined;
49
+ const v = value.trim();
50
+ if (v === '') return undefined;
51
+ const n = Number.parseInt(v, 10);
52
+ return Number.isFinite(n) ? n : undefined;
53
+ }
54
+
55
+ function envString(key) {
56
+ const raw = process.env[key];
57
+ if (typeof raw !== 'string') return undefined;
58
+ const v = raw.trim();
59
+ return v.length > 0 ? v : undefined;
60
+ }
61
+
42
62
  function resolvePackageVersion() {
43
63
  const candidates = [];
44
64
 
@@ -71,16 +91,23 @@ function resolvePackageVersion() {
71
91
  const baseConfig = {
72
92
  // Unity connection settings
73
93
  unity: {
74
- unityHost: null,
75
- mcpHost: null,
76
- bindHost: null,
94
+ unityHost: 'localhost',
95
+ mcpHost: 'localhost',
96
+ bindHost: 'localhost',
77
97
  port: 6400,
78
98
  reconnectDelay: 1000,
79
99
  maxReconnectDelay: 30000,
80
100
  reconnectBackoffMultiplier: 2,
101
+ connectTimeout: 3000,
81
102
  commandTimeout: 30000
82
103
  },
83
104
 
105
+ // Project settings (primarily for code index paths)
106
+ project: {
107
+ root: null,
108
+ codeIndexRoot: null
109
+ },
110
+
84
111
  // Server settings
85
112
  server: {
86
113
  name: 'unity-mcp-server',
@@ -136,83 +163,124 @@ const baseConfig = {
136
163
  }
137
164
  };
138
165
 
139
- /**
140
- * External config resolution:
141
- * - Uses the nearest `.unity/config.json` found by walking up from the current working directory.
142
- * - Intentionally ignores `~/.unity/config.json` (user-global).
143
- */
144
- function loadExternalConfig() {
145
- if (typeof findUpSyncCompat !== 'function') {
146
- return {};
166
+ function loadEnvConfig() {
167
+ const unityHost = envString('UNITY_MCP_UNITY_HOST');
168
+ const mcpHost = envString('UNITY_MCP_MCP_HOST');
169
+ const unityPort = parseIntEnv(process.env.UNITY_MCP_PORT);
170
+
171
+ const projectRoot = envString('UNITY_PROJECT_ROOT');
172
+
173
+ const logLevel = envString('UNITY_MCP_LOG_LEVEL');
174
+
175
+ const httpEnabled = parseBoolEnv(process.env.UNITY_MCP_HTTP_ENABLED);
176
+ const httpPort = parseIntEnv(process.env.UNITY_MCP_HTTP_PORT);
177
+
178
+ const telemetryEnabled = parseBoolEnv(process.env.UNITY_MCP_TELEMETRY_ENABLED);
179
+ const lspRequestTimeoutMs = parseIntEnv(process.env.UNITY_MCP_LSP_REQUEST_TIMEOUT_MS);
180
+
181
+ const out = {};
182
+
183
+ if (unityHost || mcpHost || unityPort !== undefined) {
184
+ out.unity = {};
185
+ if (unityHost) out.unity.unityHost = unityHost;
186
+ if (mcpHost) out.unity.mcpHost = mcpHost;
187
+ if (unityPort !== undefined) out.unity.port = unityPort;
188
+ if (out.unity.unityHost) out.unity.bindHost = out.unity.unityHost;
147
189
  }
148
- let userGlobalPath = null;
149
- try {
150
- const homeDir = os.homedir();
151
- userGlobalPath = homeDir ? path.resolve(homeDir, '.unity', 'config.json') : null;
152
- } catch {}
153
190
 
154
- const projectPath = findUpSyncCompat(
155
- directory => {
156
- const candidate = path.resolve(directory, '.unity', 'config.json');
157
- if (userGlobalPath && path.resolve(candidate) === userGlobalPath) return undefined;
158
- return fs.existsSync(candidate) ? candidate : undefined;
159
- },
160
- { cwd: process.cwd() }
161
- );
191
+ if (projectRoot) {
192
+ out.project = {};
193
+ if (projectRoot) out.project.root = projectRoot;
194
+ }
162
195
 
163
- if (!projectPath) return {};
164
- try {
165
- const raw = fs.readFileSync(projectPath, 'utf8');
166
- const json = JSON.parse(raw);
167
- const out = json && typeof json === 'object' ? json : {};
168
- out.__configPath = projectPath;
169
- return out;
170
- } catch (e) {
171
- return { __configLoadError: `${projectPath}: ${e.message}` };
196
+ if (logLevel) {
197
+ out.logging = { level: logLevel };
172
198
  }
173
- }
174
199
 
175
- const external = loadExternalConfig();
176
- export const config = merge(baseConfig, external);
200
+ if (httpEnabled !== undefined || httpPort !== undefined) {
201
+ out.http = {};
202
+ if (httpEnabled !== undefined) out.http.enabled = httpEnabled;
203
+ if (httpPort !== undefined) out.http.port = httpPort;
204
+ }
177
205
 
178
- const normalizeUnityConfig = () => {
179
- const unityConfig = config.unity || (config.unity = {});
206
+ if (telemetryEnabled !== undefined) {
207
+ out.telemetry = { enabled: telemetryEnabled };
208
+ }
180
209
 
181
- // Legacy aliases coming from config files
182
- const legacyHost = unityConfig.host;
183
- const legacyClientHost = unityConfig.clientHost;
184
- const legacyBindHost = unityConfig.bindHost;
210
+ if (lspRequestTimeoutMs !== undefined) {
211
+ out.lsp = { requestTimeoutMs: lspRequestTimeoutMs };
212
+ }
185
213
 
186
- if (!unityConfig.unityHost) {
187
- unityConfig.unityHost = legacyBindHost || legacyHost || 'localhost';
214
+ return out;
215
+ }
216
+
217
+ function validateAndNormalizeConfig(cfg) {
218
+ // Unity port
219
+ if (!Number.isInteger(cfg.unity.port) || cfg.unity.port <= 0 || cfg.unity.port >= 65536) {
220
+ console.error(
221
+ `[unity-mcp-server] WARN: Invalid UNITY_MCP_PORT (${cfg.unity.port}); using default 6400`
222
+ );
223
+ cfg.unity.port = 6400;
188
224
  }
189
225
 
190
- if (!unityConfig.mcpHost) {
191
- unityConfig.mcpHost = legacyClientHost || legacyHost || unityConfig.unityHost;
226
+ // HTTP port
227
+ if (cfg.http?.port !== undefined) {
228
+ if (!Number.isInteger(cfg.http.port) || cfg.http.port <= 0 || cfg.http.port >= 65536) {
229
+ console.error(
230
+ `[unity-mcp-server] WARN: Invalid UNITY_MCP_HTTP_PORT (${cfg.http.port}); using default 6401`
231
+ );
232
+ cfg.http.port = 6401;
233
+ }
192
234
  }
193
235
 
194
- // Keep bindHost for backwards compatibility with legacy code paths
195
- if (!unityConfig.bindHost) {
196
- unityConfig.bindHost = legacyBindHost || unityConfig.unityHost;
236
+ // logging.level
237
+ if (cfg.logging?.level) {
238
+ const level = String(cfg.logging.level).toLowerCase();
239
+ const allowed = new Set(['debug', 'info', 'warn', 'warning', 'error']);
240
+ if (!allowed.has(level)) {
241
+ console.error(
242
+ `[unity-mcp-server] WARN: Invalid UNITY_MCP_LOG_LEVEL (${cfg.logging.level}); using default info`
243
+ );
244
+ cfg.logging.level = 'info';
245
+ } else {
246
+ cfg.logging.level = level === 'warning' ? 'warn' : level;
247
+ }
197
248
  }
198
249
 
199
- // Maintain legacy properties so older handlers keep working
200
- unityConfig.host = unityConfig.unityHost;
201
- unityConfig.clientHost = unityConfig.mcpHost;
202
- };
250
+ // unity hosts
251
+ if (typeof cfg.unity.unityHost !== 'string' || cfg.unity.unityHost.trim() === '') {
252
+ cfg.unity.unityHost = 'localhost';
253
+ }
254
+ if (typeof cfg.unity.mcpHost !== 'string' || cfg.unity.mcpHost.trim() === '') {
255
+ cfg.unity.mcpHost = cfg.unity.unityHost;
256
+ }
257
+ cfg.unity.bindHost = cfg.unity.unityHost;
203
258
 
204
- normalizeUnityConfig();
259
+ // project roots (keep as-is; resolved later)
260
+ if (cfg.project?.root && typeof cfg.project.root !== 'string') {
261
+ cfg.project.root = null;
262
+ }
263
+ if (cfg.project?.codeIndexRoot && typeof cfg.project.codeIndexRoot !== 'string') {
264
+ cfg.project.codeIndexRoot = null;
265
+ }
205
266
 
206
- // Workspace root detection: directory that contains .unity/config.json used
207
- const initialCwd = process.cwd();
208
- let workspaceRoot = initialCwd;
209
- try {
210
- if (config.__configPath) {
211
- const cfgDir = path.dirname(config.__configPath); // <workspace>/.unity
212
- workspaceRoot = path.dirname(cfgDir); // <workspace>
267
+ // lsp timeout sanity
268
+ if (cfg.lsp?.requestTimeoutMs !== undefined) {
269
+ const t = Number(cfg.lsp.requestTimeoutMs);
270
+ if (!Number.isFinite(t) || t <= 0) {
271
+ console.error(
272
+ `[unity-mcp-server] WARN: Invalid UNITY_MCP_LSP_REQUEST_TIMEOUT_MS (${cfg.lsp.requestTimeoutMs}); using default 60000`
273
+ );
274
+ cfg.lsp.requestTimeoutMs = 60000;
275
+ }
213
276
  }
214
- } catch {}
215
- export const WORKSPACE_ROOT = workspaceRoot;
277
+ }
278
+
279
+ export const config = merge(baseConfig, loadEnvConfig());
280
+ validateAndNormalizeConfig(config);
281
+
282
+ // Workspace root: current working directory (used for cache/capture roots)
283
+ export const WORKSPACE_ROOT = process.cwd();
216
284
 
217
285
  /**
218
286
  * Logger utility
@@ -224,18 +292,9 @@ export const WORKSPACE_ROOT = workspaceRoot;
224
292
  */
225
293
  export const logger = new MCPLogger(config);
226
294
 
227
- // Late log if external config failed to load
228
- if (config.__configLoadError) {
229
- console.error(
230
- `${baseConfig.logging.prefix} WARN: Failed to load external config: ${config.__configLoadError}`
231
- );
232
- delete config.__configLoadError;
233
- }
234
-
235
295
  // Startup debug log: output config info to stderr for troubleshooting
236
296
  // This helps diagnose connection issues (especially in WSL2/Docker environments)
237
297
  console.error(`[unity-mcp-server] Startup config:`);
238
- console.error(`[unity-mcp-server] Config file: ${config.__configPath || '(not found)'}`);
239
298
  console.error(
240
299
  `[unity-mcp-server] Unity host: ${config.unity.mcpHost || config.unity.unityHost || 'localhost'}`
241
300
  );
@@ -46,7 +46,23 @@ export class ProjectInfoProvider {
46
46
 
47
47
  async get() {
48
48
  if (this.cached) return this.cached;
49
- // Config-driven project root (no env fallback)
49
+ // Env-driven project root override (explicit, deterministic)
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
+
65
+ // Config-driven project root (env is mapped into config.project.root)
50
66
  const cfgRootRaw = config?.project?.root;
51
67
  if (typeof cfgRootRaw === 'string' && cfgRootRaw.trim().length > 0) {
52
68
  const cfgRoot = cfgRootRaw.trim();
@@ -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,18 +116,14 @@ 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;
102
123
  }
103
124
 
104
- if (typeof cfgRootRaw === 'string') {
105
- throw new Error(
106
- 'project.root is configured but empty. Set a valid path in .unity/config.json.'
107
- );
108
- }
109
125
  throw new Error(
110
- 'Unable to resolve Unity project root. Start the server inside a Unity project (directory containing Assets/ and Packages/) or configure project.root in .unity/config.json.'
126
+ 'Unable to resolve Unity project root. Start the server inside a Unity project (directory containing Assets/ and Packages/) or set UNITY_PROJECT_ROOT.'
111
127
  );
112
128
  }
113
129
 
@@ -91,6 +91,12 @@ async function ensureInitialized() {
91
91
  // Initialize server
92
92
  export async function startServer(options = {}) {
93
93
  try {
94
+ // MCP stdio transport requires stdout to stay clean (JSON-RPC only).
95
+ // Guard against accidental console.log usage breaking the protocol.
96
+ if (options.stdioEnabled !== false && process.env.UNITY_MCP_ALLOW_STDOUT !== '1') {
97
+ console.log = (...args) => console.error(...args);
98
+ }
99
+
94
100
  // Step 1: Load minimal config for server metadata
95
101
  // (config import is lightweight; avoid importing the MCP TS SDK on startup)
96
102
  const { config: serverConfig, logger: serverLogger } = await import('./config.js');
@@ -22,6 +22,7 @@ export class UnityConnection extends EventEmitter {
22
22
  this.inFlight = 0;
23
23
  this.maxInFlight = 1; // process one command at a time by default
24
24
  this.connectedAt = null; // Timestamp when connection was established
25
+ this.hasConnectedOnce = false;
25
26
  }
26
27
 
27
28
  /**
@@ -86,6 +87,7 @@ export class UnityConnection extends EventEmitter {
86
87
  this.connected = true;
87
88
  this.reconnectAttempts = 0;
88
89
  this.connectPromise = null;
90
+ this.hasConnectedOnce = true;
89
91
  this.emit('connected');
90
92
  this._pumpQueue(); // flush any queued commands that arrived during reconnect
91
93
  settle(resolve);
@@ -120,7 +122,7 @@ export class UnityConnection extends EventEmitter {
120
122
  // Destroy the socket to clean up properly
121
123
  this.socket.destroy();
122
124
  this.isDisconnecting = false;
123
- reject(error);
125
+ reject(wrapUnityConnectError(error, targetHost, config.unity.port));
124
126
  } else if (this.connected) {
125
127
  // Force close to trigger reconnect logic
126
128
  try {
@@ -175,9 +177,11 @@ export class UnityConnection extends EventEmitter {
175
177
  this.socket.removeAllListeners();
176
178
  this.socket.destroy();
177
179
  this.connectPromise = null;
178
- settle(reject, new Error('Connection timeout'));
180
+ const timeoutError = new Error('Connection timeout');
181
+ timeoutError.code = 'ETIMEDOUT';
182
+ settle(reject, wrapUnityConnectError(timeoutError, targetHost, config.unity.port));
179
183
  }
180
- }, config.unity.commandTimeout);
184
+ }, config.unity.connectTimeout ?? config.unity.commandTimeout);
181
185
  });
182
186
  return this.connectPromise;
183
187
  }
@@ -361,7 +365,7 @@ export class UnityConnection extends EventEmitter {
361
365
  if (!this.connected) {
362
366
  logger.warning('[Unity] Not connected; waiting for reconnection before sending command');
363
367
  await this.ensureConnected({
364
- timeoutMs: config.unity.commandTimeout
368
+ timeoutMs: config.unity.connectTimeout ?? config.unity.commandTimeout
365
369
  });
366
370
  }
367
371
 
@@ -518,6 +522,13 @@ export class UnityConnection extends EventEmitter {
518
522
  if (/test environment/i.test(msg)) {
519
523
  throw error;
520
524
  }
525
+ if (
526
+ !this.hasConnectedOnce &&
527
+ (error?.code === 'UNITY_CONNECTION_REFUSED' || error?.code === 'ECONNREFUSED')
528
+ ) {
529
+ // Fail fast for the initial connection when Unity isn't listening.
530
+ throw error;
531
+ }
521
532
  }
522
533
 
523
534
  if (this.connected) return;
@@ -525,8 +536,9 @@ export class UnityConnection extends EventEmitter {
525
536
  }
526
537
 
527
538
  if (!this.connected) {
539
+ const targetHost = config.unity.mcpHost || config.unity.unityHost || 'localhost';
528
540
  const error = new Error(
529
- `Failed to reconnect to Unity within ${timeoutMs}ms${lastError ? `: ${lastError.message}` : ''}`
541
+ `Failed to connect to Unity at ${targetHost}:${config.unity.port} within ${timeoutMs}ms${lastError ? `: ${lastError.message}` : ''}`
530
542
  );
531
543
  error.code = 'UNITY_RECONNECT_TIMEOUT';
532
544
  throw error;
@@ -545,3 +557,33 @@ export class UnityConnection extends EventEmitter {
545
557
  function sleep(ms) {
546
558
  return new Promise(resolve => setTimeout(resolve, Math.max(0, ms || 0)));
547
559
  }
560
+
561
+ function wrapUnityConnectError(error, host, port) {
562
+ const code = error?.code || '';
563
+ if (code === 'ECONNREFUSED') {
564
+ const hint = buildUnityConnectionHint(host, port);
565
+ const wrapped = new Error(
566
+ `Unity TCP connection refused (ECONNREFUSED) at ${host}:${port}. ${hint}`
567
+ );
568
+ wrapped.code = 'UNITY_CONNECTION_REFUSED';
569
+ wrapped.cause = error;
570
+ return wrapped;
571
+ }
572
+ if (code === 'ETIMEDOUT') {
573
+ const hint = buildUnityConnectionHint(host, port);
574
+ const wrapped = new Error(`Unity TCP Connection timeout at ${host}:${port}. ${hint}`);
575
+ wrapped.code = 'UNITY_CONNECTION_TIMEOUT';
576
+ wrapped.cause = error;
577
+ return wrapped;
578
+ }
579
+ return error;
580
+ }
581
+
582
+ function buildUnityConnectionHint(_host, _port) {
583
+ const configPath = config.__configPath || '.unity/config.json';
584
+ return (
585
+ `Start Unity Editor and ensure the Unity MCP package is running (TCP listener). ` +
586
+ `Check ${configPath} (unity.mcpHost/unity.port). ` +
587
+ `If using WSL2/Docker → Windows Unity, set unity.mcpHost=host.docker.internal.`
588
+ );
589
+ }
@@ -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
@@ -213,42 +213,48 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
213
213
  }
214
214
  }
215
215
 
216
- // Phase 3.2: Build pathTable for deduplication (62% size reduction)
217
- const pathSet = new Set();
218
- for (const [p] of perFile) {
219
- pathSet.add(p);
220
- }
221
- const pathTable = [...pathSet];
222
- const pathIndex = new Map(pathTable.map((p, i) => [p, i]));
223
-
224
- // Pagination and byte limits with compact format
216
+ // Phase 3.3: Grouped output format (more compact, LLM-friendly)
217
+ // Pagination and byte limits with grouped format
225
218
  const results = [];
226
219
  let bytes = 0;
220
+ let total = 0;
221
+ let truncated = false;
222
+
227
223
  for (const [filePath, arr] of perFile) {
228
- const fileId = pathIndex.get(filePath);
224
+ const references = [];
229
225
  for (const it of arr) {
230
- // Compact format: fileId reference + only essential fields
231
- const compact = {
232
- fileId,
226
+ const ref = {
233
227
  line: it.line,
234
228
  column: it.column,
235
229
  snippet: it.snippet
236
230
  };
237
- const json = JSON.stringify(compact);
238
- const size = Buffer.byteLength(json, 'utf8');
239
- if (results.length >= pageSize || bytes + size > maxBytes) {
240
- return { success: true, pathTable, results, total: results.length, truncated: true };
231
+ const refJson = JSON.stringify(ref);
232
+ const refSize = Buffer.byteLength(refJson, 'utf8');
233
+
234
+ // Check limits before adding
235
+ if (total >= pageSize || bytes + refSize > maxBytes) {
236
+ truncated = true;
237
+ break;
241
238
  }
242
- results.push(compact);
243
- bytes += size;
239
+ references.push(ref);
240
+ bytes += refSize;
241
+ total++;
244
242
  }
243
+
244
+ if (references.length > 0) {
245
+ results.push({ path: filePath, references });
246
+ }
247
+
248
+ if (truncated) break;
245
249
  }
246
250
 
247
251
  // Edge case: extreme limits
248
252
  const extremeLimits = pageSize <= 1 || maxBytes <= 1;
249
- const truncated = extremeLimits && results.length === 0 ? true : false;
253
+ if (extremeLimits && results.length === 0) {
254
+ truncated = true;
255
+ }
250
256
 
251
- return { success: true, pathTable, results, total: results.length, truncated };
257
+ return { success: true, results, total, truncated };
252
258
  }
253
259
  }
254
260
 
@@ -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;
@@ -160,9 +161,8 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
160
161
  }
161
162
  const matcher = buildMatcher(patternType, pattern, flags);
162
163
 
164
+ // Phase 3.3: Grouped output format (more compact, LLM-friendly)
163
165
  const results = [];
164
- const pathTable = [];
165
- const pathId = new Map();
166
166
  let bytes = 0;
167
167
  let afterFound = !startAfter;
168
168
 
@@ -199,13 +199,8 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
199
199
  }
200
200
  if (matches === 0) continue;
201
201
 
202
- const id = pathId.has(rel)
203
- ? pathId.get(rel)
204
- : (pathTable.push(rel) - 1, pathTable.length - 1);
205
- pathId.set(rel, id);
206
-
207
202
  const lineRanges = toRanges(matchedLines);
208
- const item = { fileId: id, lineRanges };
203
+ const item = { path: rel, lineRanges };
209
204
 
210
205
  if (normalizedReturnMode === 'snippets') {
211
206
  // Build minimal snippets around first few matches
@@ -228,10 +223,9 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
228
223
  return {
229
224
  success: true,
230
225
  total: results.length,
231
- pathTable,
232
226
  results,
233
227
  cursor:
234
- results.length && results.length >= pageSize ? pathTable[pathTable.length - 1] : null
228
+ results.length && results.length >= pageSize ? results[results.length - 1].path : null
235
229
  };
236
230
  } catch (e) {
237
231
  logger.error(`[script_search] failed: ${e.message}`);
@@ -22,7 +22,7 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
22
22
  type: 'string',
23
23
  enum: ['assets', 'packages', 'embedded', 'all'],
24
24
  description:
25
- 'Search scope: assets (Assets/), packages (Packages/), embedded, or all (default: all).'
25
+ 'Search scope: assets (Assets/), packages (Packages/), embedded, or all (default: assets).'
26
26
  },
27
27
  exact: {
28
28
  type: 'boolean',
@@ -110,18 +110,15 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
110
110
  filteredRows = filteredRows.filter(r => r.name === target);
111
111
  }
112
112
 
113
- // Phase 3.1: Optimized output format with pathTable (60% size reduction)
114
- // Build pathTable for deduplication
115
- const pathSet = new Set();
113
+ // Phase 3.2: Grouped output format (40% more compact than pathTable)
114
+ // Group symbols by file path
115
+ const grouped = new Map();
116
+ let total = 0;
116
117
  for (const r of filteredRows) {
117
- pathSet.add((r.path || '').replace(/\\/g, '/'));
118
- }
119
- const pathTable = [...pathSet];
120
- const pathIndex = new Map(pathTable.map((p, i) => [p, i]));
121
-
122
- // Build compact results with fileId reference
123
- const results = filteredRows.map(r => {
124
118
  const p = (r.path || '').replace(/\\/g, '/');
119
+ if (!grouped.has(p)) {
120
+ grouped.set(p, []);
121
+ }
125
122
  const symbol = {
126
123
  name: r.name,
127
124
  kind: r.kind,
@@ -131,10 +128,16 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
131
128
  // Only include non-null optional fields
132
129
  if (r.ns) symbol.namespace = r.ns;
133
130
  if (r.container) symbol.container = r.container;
131
+ grouped.get(p).push(symbol);
132
+ total++;
133
+ }
134
134
 
135
- return { fileId: pathIndex.get(p), symbol };
136
- });
135
+ // Convert to array format
136
+ const results = [];
137
+ for (const [path, symbols] of grouped) {
138
+ results.push({ path, symbols });
139
+ }
137
140
 
138
- return { success: true, pathTable, results, total: results.length };
141
+ return { success: true, results, total };
139
142
  }
140
143
  }
@@ -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
@@ -106,14 +106,10 @@ export async function getComponentValuesHandler(unityConnection, args) {
106
106
  };
107
107
  }
108
108
 
109
- // Success response - result is already the unwrapped data
110
- console.log('[DEBUG] GetComponentValues - Full result:', JSON.stringify(result, null, 2));
111
-
112
109
  let responseText = result.summary || `Component values retrieved`;
113
110
 
114
111
  // Add detailed property information if available
115
112
  if (result.properties && Object.keys(result.properties).length > 0) {
116
- console.log('[DEBUG] Properties found:', Object.keys(result.properties).length);
117
113
  responseText += '\n\nProperties:';
118
114
  for (const [key, value] of Object.entries(result.properties)) {
119
115
  if (value && typeof value === 'object') {
@@ -144,9 +140,6 @@ export async function getComponentValuesHandler(unityConnection, args) {
144
140
  }
145
141
  }
146
142
  }
147
- } else {
148
- console.log('[DEBUG] No properties found in result');
149
- console.log('[DEBUG] Result keys:', Object.keys(result));
150
143
  }
151
144
 
152
145
  // Add debug information if available
@@ -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