@akiojin/unity-mcp-server 2.41.8 → 2.42.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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Worker Thread script for code index builds.
3
+ *
4
+ * This script runs in a separate thread and performs the heavy lifting of
5
+ * index builds: file scanning, LSP document symbol requests, and SQLite
6
+ * database operations. By running in a Worker Thread, these synchronous
7
+ * operations don't block the main event loop.
8
+ *
9
+ * Communication with main thread:
10
+ * - Receives: workerData with build options
11
+ * - Sends: progress updates, completion, errors, logs
12
+ */
13
+
14
+ import { parentPort, workerData } from 'worker_threads';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = path.dirname(__filename);
21
+
22
+ // Helper to send messages to main thread
23
+ function sendMessage(type, data) {
24
+ if (parentPort) {
25
+ parentPort.postMessage({ type, ...data });
26
+ }
27
+ }
28
+
29
+ function log(level, message) {
30
+ sendMessage('log', { level, message });
31
+ }
32
+
33
+ function sendProgress(processed, total, rate) {
34
+ sendMessage('progress', { data: { processed, total, rate } });
35
+ }
36
+
37
+ function sendComplete(result) {
38
+ sendMessage('complete', { data: result });
39
+ }
40
+
41
+ function sendError(error) {
42
+ sendMessage('error', { error: error.message || String(error) });
43
+ }
44
+
45
+ /**
46
+ * Walk directory tree and collect .cs files
47
+ */
48
+ function walkCs(root, files, seen) {
49
+ try {
50
+ if (!fs.existsSync(root)) return;
51
+ const st = fs.statSync(root);
52
+ if (st.isFile()) {
53
+ if (root.endsWith('.cs') && !seen.has(root)) {
54
+ files.push(root);
55
+ seen.add(root);
56
+ }
57
+ return;
58
+ }
59
+ const entries = fs.readdirSync(root, { withFileTypes: true });
60
+ for (const e of entries) {
61
+ if (e.name === 'obj' || e.name === 'bin' || e.name.startsWith('.')) continue;
62
+ walkCs(path.join(root, e.name), files, seen);
63
+ }
64
+ } catch {
65
+ // Ignore errors
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Convert absolute path to relative path
71
+ */
72
+ function toRel(full, projectRoot) {
73
+ const normFull = String(full).replace(/\\/g, '/');
74
+ const normRoot = String(projectRoot).replace(/\\/g, '/').replace(/\/$/, '');
75
+ return normFull.startsWith(normRoot) ? normFull.substring(normRoot.length + 1) : normFull;
76
+ }
77
+
78
+ /**
79
+ * Create file signature for change detection
80
+ */
81
+ function makeSig(abs) {
82
+ try {
83
+ const st = fs.statSync(abs);
84
+ return `${st.size}-${Math.floor(st.mtimeMs)}`;
85
+ } catch {
86
+ return '0-0';
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Convert LSP symbol kind to string
92
+ */
93
+ function kindFromLsp(k) {
94
+ switch (k) {
95
+ case 5:
96
+ return 'class';
97
+ case 23:
98
+ return 'struct';
99
+ case 11:
100
+ return 'interface';
101
+ case 10:
102
+ return 'enum';
103
+ case 6:
104
+ return 'method';
105
+ case 7:
106
+ return 'property';
107
+ case 8:
108
+ return 'field';
109
+ case 3:
110
+ return 'namespace';
111
+ default:
112
+ return 'unknown';
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Convert LSP document symbols to database rows
118
+ */
119
+ function toRows(uri, symbols, projectRoot) {
120
+ const rel = toRel(uri.replace('file://', ''), projectRoot);
121
+ const rows = [];
122
+
123
+ const visit = (s, container) => {
124
+ const kind = kindFromLsp(s.kind);
125
+ const name = s.name || '';
126
+ const start = s.range?.start || s.selectionRange?.start || {};
127
+ rows.push({
128
+ path: rel,
129
+ name,
130
+ kind,
131
+ container: container || null,
132
+ ns: null,
133
+ line: (start.line ?? 0) + 1,
134
+ column: (start.character ?? 0) + 1
135
+ });
136
+ if (Array.isArray(s.children)) {
137
+ for (const c of s.children) visit(c, name || container);
138
+ }
139
+ };
140
+
141
+ if (Array.isArray(symbols)) {
142
+ for (const s of symbols) visit(s, null);
143
+ }
144
+ return rows;
145
+ }
146
+
147
+ /**
148
+ * Main build function
149
+ */
150
+ async function runBuild() {
151
+ const {
152
+ projectRoot,
153
+ dbPath,
154
+ concurrency: _concurrency = 1, // Reserved for future parallel processing
155
+ throttleMs = 0,
156
+ retry = 2,
157
+ reportPercentage = 10
158
+ } = workerData;
159
+ void _concurrency; // Explicitly mark as intentionally unused
160
+
161
+ log('info', `[worker] Starting build: projectRoot=${projectRoot}, dbPath=${dbPath}`);
162
+
163
+ try {
164
+ // Dynamic import better-sqlite3 in worker thread
165
+ let Database;
166
+ try {
167
+ const mod = await import('better-sqlite3');
168
+ Database = mod.default || mod;
169
+ } catch (e) {
170
+ throw new Error(`better-sqlite3 unavailable in worker: ${e.message}`);
171
+ }
172
+
173
+ // Open database
174
+ const db = new Database(dbPath);
175
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;');
176
+
177
+ // Initialize schema if needed
178
+ db.exec(`
179
+ CREATE TABLE IF NOT EXISTS meta (
180
+ key TEXT PRIMARY KEY,
181
+ value TEXT
182
+ );
183
+ CREATE TABLE IF NOT EXISTS files (
184
+ path TEXT PRIMARY KEY,
185
+ sig TEXT,
186
+ updatedAt TEXT
187
+ );
188
+ CREATE TABLE IF NOT EXISTS symbols (
189
+ path TEXT NOT NULL,
190
+ name TEXT NOT NULL,
191
+ kind TEXT NOT NULL,
192
+ container TEXT,
193
+ namespace TEXT,
194
+ line INTEGER,
195
+ column INTEGER
196
+ );
197
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
198
+ CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
199
+ CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path);
200
+ `);
201
+
202
+ // Scan for C# files
203
+ const roots = [
204
+ path.resolve(projectRoot, 'Assets'),
205
+ path.resolve(projectRoot, 'Packages'),
206
+ path.resolve(projectRoot, 'Library/PackageCache')
207
+ ];
208
+ const files = [];
209
+ const seen = new Set();
210
+ for (const r of roots) walkCs(r, files, seen);
211
+
212
+ log('info', `[worker] Found ${files.length} C# files to process`);
213
+
214
+ // Get current indexed files
215
+ const currentRows = db.prepare('SELECT path, sig FROM files').all();
216
+ const current = new Map(currentRows.map(r => [r.path, r.sig]));
217
+
218
+ // Determine changes
219
+ const wanted = new Map(files.map(abs => [toRel(abs, projectRoot), makeSig(abs)]));
220
+ const changed = [];
221
+ const removed = [];
222
+
223
+ for (const [rel, sig] of wanted) {
224
+ if (current.get(rel) !== sig) changed.push(rel);
225
+ }
226
+ for (const [rel] of current) {
227
+ if (!wanted.has(rel)) removed.push(rel);
228
+ }
229
+
230
+ log('info', `[worker] Changes: ${changed.length} to update, ${removed.length} to remove`);
231
+
232
+ // Remove vanished files
233
+ const deleteSymbols = db.prepare('DELETE FROM symbols WHERE path = ?');
234
+ const deleteFile = db.prepare('DELETE FROM files WHERE path = ?');
235
+ for (const rel of removed) {
236
+ deleteSymbols.run(rel);
237
+ deleteFile.run(rel);
238
+ }
239
+
240
+ // Prepare for updates
241
+ const absList = changed.map(rel => path.resolve(projectRoot, rel));
242
+ const startAt = Date.now();
243
+ let processed = 0;
244
+ let updated = 0;
245
+ let lastReportedPercentage = 0;
246
+
247
+ // Initialize LSP connection
248
+ // Note: LspRpcClientSingleton is in src/lsp/, not src/core/lsp/
249
+ let lsp = null;
250
+ try {
251
+ const lspModulePath = path.join(__dirname, '..', '..', 'lsp', 'LspRpcClientSingleton.js');
252
+ const { LspRpcClientSingleton } = await import(`file://${lspModulePath}`);
253
+ lsp = await LspRpcClientSingleton.getInstance(projectRoot);
254
+ log('info', `[worker] LSP initialized`);
255
+ } catch (e) {
256
+ throw new Error(`LSP initialization failed: ${e.message}`);
257
+ }
258
+
259
+ // LSP request with retry
260
+ const requestWithRetry = async (uri, maxRetries = retry) => {
261
+ let lastErr = null;
262
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
263
+ try {
264
+ const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
265
+ return res?.result ?? res;
266
+ } catch (err) {
267
+ lastErr = err;
268
+ await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
269
+ }
270
+ }
271
+ throw lastErr || new Error('documentSymbol failed');
272
+ };
273
+
274
+ // Prepared statements for updates
275
+ const insertSymbol = db.prepare(
276
+ 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
277
+ );
278
+ const deleteSymbolsForPath = db.prepare('DELETE FROM symbols WHERE path = ?');
279
+ const upsertFile = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
280
+ const updateMeta = db.prepare("REPLACE INTO meta(key,value) VALUES ('lastIndexedAt',?)");
281
+
282
+ // Process files sequentially (concurrency=1 for non-blocking)
283
+ for (let i = 0; i < absList.length; i++) {
284
+ const abs = absList[i];
285
+ const rel = toRel(abs, projectRoot);
286
+
287
+ try {
288
+ const uri = 'file://' + abs.replace(/\\/g, '/');
289
+ const docSymbols = await requestWithRetry(uri);
290
+ const rows = toRows(uri, docSymbols, projectRoot);
291
+
292
+ // Update database in transaction
293
+ db.transaction(() => {
294
+ deleteSymbolsForPath.run(rel);
295
+ for (const r of rows) {
296
+ insertSymbol.run(r.path, r.name, r.kind, r.container, r.ns, r.line, r.column);
297
+ }
298
+ upsertFile.run(rel, wanted.get(rel), new Date().toISOString());
299
+ updateMeta.run(new Date().toISOString());
300
+ })();
301
+
302
+ updated++;
303
+ } catch (err) {
304
+ // Log occasionally to avoid spam
305
+ if (processed % 50 === 0) {
306
+ log('warn', `[worker] Skipped file: ${rel} - ${err.message}`);
307
+ }
308
+ }
309
+
310
+ processed++;
311
+
312
+ // Send progress update
313
+ const elapsed = Math.max(1, Date.now() - startAt);
314
+ const rate = parseFloat(((processed * 1000) / elapsed).toFixed(1));
315
+ const currentPercentage = Math.floor((processed / absList.length) * 100);
316
+
317
+ if (
318
+ currentPercentage >= lastReportedPercentage + reportPercentage ||
319
+ processed === absList.length
320
+ ) {
321
+ sendProgress(processed, absList.length, rate);
322
+ log(
323
+ 'info',
324
+ `[worker] progress ${currentPercentage}% (${processed}/${absList.length}) rate:${rate} f/s`
325
+ );
326
+ lastReportedPercentage = currentPercentage;
327
+ }
328
+
329
+ // Throttle if requested (for testing)
330
+ if (throttleMs > 0) {
331
+ await new Promise(r => setTimeout(r, throttleMs));
332
+ }
333
+ }
334
+
335
+ // Get final stats
336
+ const total = db.prepare('SELECT COUNT(*) AS c FROM symbols').get().c || 0;
337
+ const lastIndexedAt =
338
+ db.prepare("SELECT value AS v FROM meta WHERE key = 'lastIndexedAt'").get()?.v || null;
339
+
340
+ db.close();
341
+
342
+ const result = {
343
+ updatedFiles: updated,
344
+ removedFiles: removed.length,
345
+ totalIndexedSymbols: total,
346
+ lastIndexedAt
347
+ };
348
+
349
+ log(
350
+ 'info',
351
+ `[worker] Build completed: updated=${result.updatedFiles}, removed=${result.removedFiles}, total=${result.totalIndexedSymbols}`
352
+ );
353
+
354
+ sendComplete(result);
355
+ } catch (error) {
356
+ log('error', `[worker] Build failed: ${error.message}`);
357
+ sendError(error);
358
+ }
359
+ }
360
+
361
+ // Run the build
362
+ runBuild();
@@ -234,6 +234,11 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
234
234
  lastReportedPercentage = currentPercentage;
235
235
  }
236
236
 
237
+ // Yield to event loop after each file to allow MCP requests to be processed
238
+ // This prevents the index build from blocking other MCP tool calls (US-8.1)
239
+ // Use setTimeout(1) instead of setImmediate to ensure MCP I/O callbacks get processed
240
+ await new Promise(resolve => setTimeout(resolve, 1));
241
+
237
242
  if (throttleMs > 0) {
238
243
  await new Promise(resolve => setTimeout(resolve, throttleMs));
239
244
  }
@@ -1,7 +1,4 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
4
- import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
2
  import { JobManager } from '../../core/jobManager.js';
6
3
  import { CodeIndex } from '../../core/codeIndex.js';
7
4
 
@@ -17,7 +14,6 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
17
14
  }
18
15
  );
19
16
  this.unityConnection = unityConnection;
20
- this.projectInfo = new ProjectInfoProvider(unityConnection);
21
17
  this.jobManager = JobManager.getInstance();
22
18
  this.codeIndex = new CodeIndex(unityConnection);
23
19
  }
@@ -34,13 +30,25 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
34
30
  const ready = await this.codeIndex.isReady();
35
31
  if (this.codeIndex.disabled) {
36
32
  return {
37
- success: false,
38
- error: 'code_index_unavailable',
33
+ success: true,
34
+ status: 'degraded',
35
+ disabled: true,
36
+ ready: false,
37
+ totalFiles: 0,
38
+ indexedFiles: 0,
39
+ coverage: 0,
39
40
  message:
40
41
  this.codeIndex.disableReason ||
41
42
  'Code index is disabled because the SQLite driver could not be loaded. The server will continue without the symbol index.',
42
43
  remediation:
43
- 'Install native build tools (python3, make, g++) in this environment and run "npm rebuild better-sqlite3 --build-from-source", then restart the server.'
44
+ 'Install native build tools (python3, make, g++) and run "npm rebuild better-sqlite3 --build-from-source", or set UNITY_MCP_SKIP_NATIVE_BUILD=0 to allow native rebuild. After installing, restart unity-mcp-server.',
45
+ index: {
46
+ ready: false,
47
+ disabled: true,
48
+ reason:
49
+ this.codeIndex.disableReason ||
50
+ 'better-sqlite3 native binding unavailable; code index is disabled'
51
+ }
44
52
  };
45
53
  }
46
54
  const buildInProgress = latestBuildJob?.status === 'running';
@@ -52,58 +60,13 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
52
60
  };
53
61
  }
54
62
 
55
- let projectInfo;
56
- try {
57
- projectInfo = await this.projectInfo.get();
58
- } catch (error) {
59
- return {
60
- success: false,
61
- error: 'project_info_unavailable',
62
- message: error.message || 'Unable to resolve Unity project root.'
63
- };
64
- }
65
-
66
- const projectRoot = projectInfo.projectRoot.replace(/\\/g, '/');
67
- const roots = [
68
- path.resolve(projectRoot, 'Assets'),
69
- path.resolve(projectRoot, 'Packages'),
70
- path.resolve(projectRoot, 'Library/PackageCache')
71
- ];
72
-
73
- let totalFiles = 0;
74
- const breakdown = { assets: 0, packages: 0, packageCache: 0, other: 0 };
75
-
76
- const visit = targetPath => {
77
- try {
78
- if (!fs.existsSync(targetPath)) return;
79
- const stats = fs.statSync(targetPath);
80
- if (stats.isFile()) {
81
- if (!targetPath.endsWith('.cs')) return;
82
- totalFiles += 1;
83
- const normalized = targetPath.replace(/\\/g, '/');
84
- const relative = normalized.replace(projectRoot, '').replace(/^\//, '');
85
- if (relative.startsWith('Assets/')) breakdown.assets += 1;
86
- else if (relative.startsWith('Packages/')) breakdown.packages += 1;
87
- else if (relative.includes('Library/PackageCache/')) breakdown.packageCache += 1;
88
- else breakdown.other += 1;
89
- return;
90
- }
91
-
92
- if (stats.isDirectory()) {
93
- for (const child of fs.readdirSync(targetPath)) {
94
- if (child === 'obj' || child === 'bin' || child.startsWith('.')) continue;
95
- visit(path.join(targetPath, child));
96
- }
97
- }
98
- } catch (error) {
99
- // Ignore permission errors while traversing; status reporting should not fail because of a single file.
100
- }
101
- };
102
-
103
- for (const root of roots) visit(root);
104
-
63
+ // Use DB stats directly for fast status check - avoid expensive filesystem traversal
105
64
  const stats = await this.codeIndex.getStats();
106
- const coverage = totalFiles > 0 ? Math.min(1, stats.total / totalFiles) : 0;
65
+
66
+ // Estimate total files from DB metadata if available, otherwise use indexed count
67
+ // The actual file count scan is too slow (6+ minutes for 7000+ files)
68
+ const totalFiles = stats.totalFilesEstimate || stats.total;
69
+ const coverage = totalFiles > 0 ? Math.min(1, stats.total / totalFiles) : 1;
107
70
 
108
71
  const indexInfo = {
109
72
  ready,
@@ -134,7 +97,6 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
134
97
  totalFiles,
135
98
  indexedFiles: stats.total,
136
99
  coverage,
137
- breakdown,
138
100
  index: indexInfo
139
101
  };
140
102
  }
@@ -1,5 +1,6 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { CodeIndex } from '../../core/codeIndex.js';
3
+ import { JobManager } from '../../core/jobManager.js';
3
4
  import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
4
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
6
 
@@ -60,6 +61,7 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
60
61
  this.unityConnection = unityConnection;
61
62
  this.index = new CodeIndex(unityConnection);
62
63
  this.projectInfo = new ProjectInfoProvider(unityConnection);
64
+ this.jobManager = JobManager.getInstance();
63
65
  this.lsp = null;
64
66
  }
65
67
 
@@ -82,7 +84,56 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
82
84
  maxMatchesPerFile = 5
83
85
  } = params;
84
86
 
85
- // LSP拡張へ委譲(mcp/referencesByName)
87
+ // Check if code index is ready - required for references search
88
+ const ready = await this.index.isReady();
89
+ if (this.index.disabled) {
90
+ return {
91
+ success: false,
92
+ error: 'code_index_unavailable',
93
+ message:
94
+ this.index.disableReason ||
95
+ 'Code index is disabled because the SQLite driver could not be loaded.',
96
+ remediation:
97
+ 'Install native build tools and run "npm rebuild better-sqlite3 --build-from-source", then restart the server.'
98
+ };
99
+ }
100
+
101
+ if (!ready) {
102
+ // Check if a build job is currently running
103
+ const allJobs = this.jobManager.getAllJobs();
104
+ const buildJob = allJobs.find(
105
+ job =>
106
+ (job.id.startsWith('build-') || job.id.startsWith('watcher-')) && job.status === 'running'
107
+ );
108
+
109
+ if (buildJob) {
110
+ const progress = buildJob.progress || {};
111
+ const pct =
112
+ progress.total > 0 ? Math.round((progress.processed / progress.total) * 100) : 0;
113
+ return {
114
+ success: false,
115
+ error: 'index_building',
116
+ message: `Code index is currently being built. Please wait and retry. Progress: ${pct}% (${progress.processed || 0}/${progress.total || 0})`,
117
+ jobId: buildJob.id,
118
+ progress: {
119
+ processed: progress.processed || 0,
120
+ total: progress.total || 0,
121
+ percentage: pct
122
+ }
123
+ };
124
+ }
125
+
126
+ // No build job running - index not available
127
+ return {
128
+ success: false,
129
+ error: 'index_not_ready',
130
+ message:
131
+ 'Code index is not built. Run code_index_build first, or wait for auto-build to complete on server startup.',
132
+ hint: 'Use code_index_status to check index state, or code_index_build to start a build manually.'
133
+ };
134
+ }
135
+
136
+ // DB index is ready - use LSP for references (LSP requires index for performance)
86
137
  const info = await this.projectInfo.get();
87
138
  if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
88
139
  const resp = await this.lsp.request('mcp/referencesByName', { name: String(name) });
@@ -120,7 +120,7 @@ export class ScriptSearchToolHandler extends BaseToolHandler {
120
120
  pattern,
121
121
  patternType = 'substring',
122
122
  flags = [],
123
- scope = 'assets',
123
+ scope = 'all',
124
124
  include = '**/*.cs',
125
125
  exclude,
126
126
  pageSize = 20,