@akiojin/unity-mcp-server 2.45.0 → 2.45.2

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": "2.45.0",
3
+ "version": "2.45.2",
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",
@@ -50,7 +50,7 @@
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "^1.24.3",
52
52
  "find-up": "^6.3.0",
53
- "@akiojin/fast-sql": "file:../packages/fast-sql"
53
+ "@akiojin/fast-sql": "^0.1.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=18 <23"
@@ -1,11 +1,8 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
- import { CodeIndex } from '../../core/codeIndex.js';
3
- import fs from 'fs';
4
2
  import path from 'path';
5
3
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
- import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
- import { logger } from '../../core/config.js';
8
4
  import { JobManager } from '../../core/jobManager.js';
5
+ import { getWorkerPool } from '../../core/indexBuildWorkerPool.js';
9
6
 
10
7
  export class CodeIndexBuildToolHandler extends BaseToolHandler {
11
8
  constructor(unityConnection) {
@@ -21,26 +18,25 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
21
18
  description:
22
19
  'Optional delay in milliseconds after processing each file (testing/debugging).'
23
20
  },
24
- delayStartMs: {
21
+ retry: {
25
22
  type: 'number',
26
23
  minimum: 0,
27
- description:
28
- 'Optional delay before processing begins (useful to keep job in running state briefly).'
24
+ maximum: 5,
25
+ description: 'Number of retries for LSP requests (default: 2).'
29
26
  }
30
27
  },
31
28
  required: []
32
29
  }
33
30
  );
34
31
  this.unityConnection = unityConnection;
35
- this.index = new CodeIndex(unityConnection);
36
32
  this.projectInfo = new ProjectInfoProvider(unityConnection);
37
- this.lsp = null; // lazy init with projectRoot
38
33
  this.jobManager = JobManager.getInstance();
34
+ this.workerPool = getWorkerPool();
39
35
  this.currentJobId = null; // Track current running job
40
36
  }
41
37
 
42
38
  async execute(params = {}) {
43
- // Check if a build is already running
39
+ // Check if a build is already running via JobManager
44
40
  if (this.currentJobId) {
45
41
  const existingJob = this.jobManager.get(this.currentJobId);
46
42
  if (existingJob && existingJob.status === 'running') {
@@ -53,283 +49,62 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
53
49
  }
54
50
  }
55
51
 
52
+ // Check Worker Pool status (prevents concurrent Worker Thread builds)
53
+ if (this.workerPool.isRunning()) {
54
+ return {
55
+ success: false,
56
+ error: 'build_already_running',
57
+ message: 'Code index build is already running (Worker Thread). Use code_index_status to check progress.',
58
+ jobId: this.currentJobId
59
+ };
60
+ }
61
+
56
62
  // Generate new jobId
57
63
  const jobId = `build-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
58
64
  this.currentJobId = jobId;
59
65
 
60
- // Create background job
66
+ // Get project info for DB path
67
+ const info = await this.projectInfo.get();
68
+ const dbPath = path.join(info.codeIndexRoot, 'code-index.db');
69
+
70
+ // Create background job using Worker Thread (non-blocking)
71
+ // This is the key change: execute build in Worker Thread instead of main thread
61
72
  this.jobManager.create(jobId, async job => {
62
- return await this._executeBuild(params, job);
73
+ // Initialize progress
74
+ job.progress = { processed: 0, total: 0, rate: 0 };
75
+
76
+ // Subscribe to progress updates from Worker Thread
77
+ this.workerPool.onProgress(progress => {
78
+ job.progress = progress;
79
+ });
80
+
81
+ try {
82
+ // Execute build in Worker Thread (non-blocking for main event loop)
83
+ const result = await this.workerPool.executeBuild({
84
+ projectRoot: info.projectRoot,
85
+ dbPath: dbPath,
86
+ concurrency: 1, // Worker Thread uses sequential processing for stability
87
+ throttleMs: Math.max(0, Number(params?.throttleMs ?? 0)),
88
+ retry: Math.max(0, Math.min(5, Number(params?.retry ?? 2))),
89
+ reportPercentage: 10
90
+ });
91
+
92
+ // Clear current job tracking on success
93
+ this.currentJobId = null;
94
+ return result;
95
+ } catch (error) {
96
+ // Clear current job tracking on error
97
+ this.currentJobId = null;
98
+ throw error;
99
+ }
63
100
  });
64
101
 
65
- // Return immediately with jobId
102
+ // Return immediately with jobId (non-blocking)
66
103
  return {
67
104
  success: true,
68
105
  jobId,
69
- message: 'Code index build started in background',
106
+ message: 'Code index build started in background (Worker Thread)',
70
107
  checkStatus: 'Use code_index_status to check progress and completion'
71
108
  };
72
109
  }
73
-
74
- /**
75
- * Internal method that performs the actual build
76
- * @private
77
- */
78
- async _executeBuild(params, job) {
79
- try {
80
- // Fail fast when native SQLite binding is unavailable
81
- const db = await this.index.open();
82
- if (!db) {
83
- const reason = this.index.disableReason || 'Code index unavailable (SQLite driver missing)';
84
- throw new Error(reason);
85
- }
86
-
87
- const throttleMs = Math.max(0, Number(params?.throttleMs ?? 0));
88
- const delayStartMs = Math.max(0, Number(params?.delayStartMs ?? 0));
89
- const info = await this.projectInfo.get();
90
- const roots = [
91
- path.resolve(info.projectRoot, 'Assets'),
92
- path.resolve(info.projectRoot, 'Packages'),
93
- path.resolve(info.projectRoot, 'Library/PackageCache')
94
- ];
95
- const files = [];
96
- const seen = new Set();
97
- for (const r of roots) this.walkCs(r, files, seen);
98
-
99
- // Initialize LSP with error handling
100
- if (!this.lsp) {
101
- try {
102
- this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
103
- logger.info(`[index][${job.id}] LSP initialized for project: ${info.projectRoot}`);
104
- } catch (lspError) {
105
- logger.error(`[index][${job.id}] LSP initialization failed: ${lspError.message}`);
106
- throw new Error(
107
- `LSP initialization failed: ${lspError.message}. Ensure C# LSP is properly configured and OmniSharp is available.`
108
- );
109
- }
110
- }
111
- const lsp = this.lsp;
112
-
113
- // Incremental detection based on size-mtime signature
114
- const makeSig = abs => {
115
- try {
116
- const st = fs.statSync(abs);
117
- return `${st.size}-${Math.floor(st.mtimeMs)}`;
118
- } catch {
119
- return '0-0';
120
- }
121
- };
122
- const wanted = new Map(files.map(abs => [this.toRel(abs, info.projectRoot), makeSig(abs)]));
123
- const current = await this.index.getFiles();
124
- const changed = [];
125
- const removed = [];
126
- for (const [rel, sig] of wanted) {
127
- if (current.get(rel) !== sig) changed.push(rel);
128
- }
129
- for (const [rel] of current) if (!wanted.has(rel)) removed.push(rel);
130
- const toRows = (uri, symbols) => {
131
- const rel = this.toRel(uri.replace('file://', ''), info.projectRoot);
132
- const rows = [];
133
- const visit = (s, container) => {
134
- const kind = this.kindFromLsp(s.kind);
135
- const name = s.name || '';
136
- const start = s.range?.start || s.selectionRange?.start || {};
137
- rows.push({
138
- path: rel,
139
- name,
140
- kind,
141
- container: container || null,
142
- ns: null,
143
- line: (start.line ?? 0) + 1,
144
- column: (start.character ?? 0) + 1
145
- });
146
- if (Array.isArray(s.children)) for (const c of s.children) visit(c, name || container);
147
- };
148
- if (Array.isArray(symbols)) for (const s of symbols) visit(s, null);
149
- return rows;
150
- };
151
-
152
- // Remove vanished files
153
- for (const rel of removed) await this.index.removeFile(rel);
154
-
155
- // Update changed files
156
- const absList = changed.map(rel => path.resolve(info.projectRoot, rel));
157
- const concurrency = Math.max(1, Math.min(64, Number(params?.concurrency ?? 8)));
158
- const reportPercentage = Math.max(1, Math.min(100, Number(params?.reportPercentage ?? 10)));
159
- const startAt = Date.now();
160
- let i = 0;
161
- let updated = 0;
162
- let processed = 0;
163
- let lastReportedPercentage = 0;
164
-
165
- // Initialize progress
166
- job.progress.total = absList.length;
167
- job.progress.processed = 0;
168
- job.progress.rate = 0;
169
-
170
- logger.info(
171
- `[index][${job.id}] Build started: ${absList.length} files to process, ${removed.length} to remove (status: ${job.status})`
172
- );
173
-
174
- // LSP request with small retry/backoff
175
- const requestWithRetry = async (
176
- uri,
177
- maxRetries = Math.max(0, Math.min(5, Number(params?.retry ?? 2)))
178
- ) => {
179
- let lastErr = null;
180
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
181
- try {
182
- const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
183
- return res?.result ?? res;
184
- } catch (err) {
185
- lastErr = err;
186
- await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
187
- }
188
- }
189
- throw lastErr || new Error('documentSymbol failed');
190
- };
191
- if (delayStartMs > 0) {
192
- await new Promise(resolve => setTimeout(resolve, delayStartMs));
193
- }
194
-
195
- const worker = async () => {
196
- while (true) {
197
- const idx = i++;
198
- if (idx >= absList.length) break;
199
- const abs = absList[idx];
200
- const rel = this.toRel(abs, info.projectRoot);
201
- try {
202
- const uri = 'file://' + abs.replace(/\\/g, '/');
203
- const docSymbols = await requestWithRetry(uri, 2);
204
- const rows = toRows(uri, docSymbols);
205
- await this.index.replaceSymbolsForPath(rel, rows);
206
- await this.index.upsertFile(rel, wanted.get(rel));
207
- updated += 1;
208
- } catch (err) {
209
- // File access or LSP error - skip and continue
210
- // This allows build to continue even if some files fail
211
- if (processed % 50 === 0) {
212
- // Log occasionally to avoid spam
213
- logger.warning(
214
- `[index][${job.id}] Skipped file due to error: ${rel} - ${err.message}`
215
- );
216
- }
217
- } finally {
218
- processed += 1;
219
-
220
- // Update job progress
221
- const elapsed = Math.max(1, Date.now() - startAt);
222
- job.progress.processed = processed;
223
- job.progress.rate = parseFloat(((processed * 1000) / elapsed).toFixed(1));
224
-
225
- // Calculate current percentage
226
- const currentPercentage = Math.floor((processed / absList.length) * 100);
227
-
228
- // Log when percentage increases by reportPercentage (default: 10%)
229
- if (
230
- currentPercentage >= lastReportedPercentage + reportPercentage ||
231
- processed === absList.length
232
- ) {
233
- logger.info(
234
- `[index][${job.id}] progress ${currentPercentage}% (${processed}/${absList.length}) removed:${removed.length} rate:${job.progress.rate} f/s`
235
- );
236
- lastReportedPercentage = currentPercentage;
237
- }
238
-
239
- // Yield to event loop after each file to allow MCP requests to be processed
240
- // This prevents the index build from blocking other MCP tool calls (US-8.1)
241
- // Use setTimeout(1) instead of setImmediate to ensure MCP I/O callbacks get processed
242
- await new Promise(resolve => setTimeout(resolve, 1));
243
-
244
- if (throttleMs > 0) {
245
- await new Promise(resolve => setTimeout(resolve, throttleMs));
246
- }
247
- }
248
- }
249
- };
250
- const workers = Array.from({ length: Math.min(concurrency, absList.length) }, () => worker());
251
- await Promise.all(workers);
252
-
253
- const stats = await this.index.getStats();
254
-
255
- // Clear current job tracking on success
256
- if (this.currentJobId === job.id) {
257
- this.currentJobId = null;
258
- }
259
-
260
- const result = {
261
- updatedFiles: updated,
262
- removedFiles: removed.length,
263
- totalIndexedSymbols: stats.total,
264
- lastIndexedAt: stats.lastIndexedAt
265
- };
266
-
267
- logger.info(
268
- `[index][${job.id}] Build completed successfully: updated=${result.updatedFiles}, removed=${result.removedFiles}, total=${result.totalIndexedSymbols} (status: completed)`
269
- );
270
-
271
- return result;
272
- } catch (e) {
273
- // Clear current job tracking on error
274
- if (this.currentJobId === job.id) {
275
- this.currentJobId = null;
276
- }
277
-
278
- // Log detailed error with job context
279
- logger.error(`[index][${job.id}] Build failed: ${e.message} (status: failed)`);
280
-
281
- // Provide helpful error message with context
282
- const errorMessage = e.message.includes('LSP')
283
- ? `LSP error: ${e.message}`
284
- : `Build error: ${e.message}. Hint: C# LSP not ready. Ensure manifest/auto-download and workspace paths are valid.`;
285
-
286
- throw new Error(errorMessage);
287
- }
288
- }
289
-
290
- walkCs(root, files, seen) {
291
- try {
292
- if (!fs.existsSync(root)) return;
293
- const st = fs.statSync(root);
294
- if (st.isFile()) {
295
- if (root.endsWith('.cs') && !seen.has(root)) {
296
- files.push(root);
297
- seen.add(root);
298
- }
299
- return;
300
- }
301
- const entries = fs.readdirSync(root, { withFileTypes: true });
302
- for (const e of entries) {
303
- if (e.name === 'obj' || e.name === 'bin' || e.name.startsWith('.')) continue;
304
- this.walkCs(path.join(root, e.name), files, seen);
305
- }
306
- } catch {}
307
- }
308
-
309
- toRel(full, projectRoot) {
310
- const normFull = String(full).replace(/\\/g, '/');
311
- const normRoot = String(projectRoot).replace(/\\/g, '/').replace(/\/$/, '');
312
- return normFull.startsWith(normRoot) ? normFull.substring(normRoot.length + 1) : normFull;
313
- }
314
-
315
- kindFromLsp(k) {
316
- switch (k) {
317
- case 5:
318
- return 'class';
319
- case 23:
320
- return 'struct';
321
- case 11:
322
- return 'interface';
323
- case 10:
324
- return 'enum';
325
- case 6:
326
- return 'method';
327
- case 7:
328
- return 'property';
329
- case 8:
330
- return 'field';
331
- case 3:
332
- return 'namespace';
333
- }
334
- }
335
110
  }