@akiojin/unity-mcp-server 2.45.4 → 2.46.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.
@@ -9,11 +9,16 @@
9
9
  * Communication with main thread:
10
10
  * - Receives: workerData with build options
11
11
  * - Sends: progress updates, completion, errors, logs
12
+ *
13
+ * IMPORTANT: This worker uses an inline LSP client implementation to avoid
14
+ * importing main thread modules that may not work correctly in Worker Threads.
12
15
  */
13
16
 
14
17
  import { parentPort, workerData } from 'worker_threads';
18
+ import { spawn } from 'child_process';
15
19
  import fs from 'fs';
16
20
  import path from 'path';
21
+ import os from 'os';
17
22
  import { fileURLToPath } from 'url';
18
23
 
19
24
  // fast-sql helper: run SQL statement
@@ -123,6 +128,217 @@ function makeSig(abs) {
123
128
  }
124
129
  }
125
130
 
131
+ // ============================================================================
132
+ // Worker-local LSP Client Implementation
133
+ // ============================================================================
134
+ // This is an inline implementation specifically for Worker Threads.
135
+ // It does NOT share state with main thread and manages its own LSP process.
136
+
137
+ /**
138
+ * Detect runtime identifier for csharp-lsp binary
139
+ */
140
+ function detectRid() {
141
+ if (process.platform === 'win32') return process.arch === 'arm64' ? 'win-arm64' : 'win-x64';
142
+ if (process.platform === 'darwin') return process.arch === 'arm64' ? 'osx-arm64' : 'osx-x64';
143
+ return process.arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
144
+ }
145
+
146
+ /**
147
+ * Get csharp-lsp executable name
148
+ */
149
+ function getExecutableName() {
150
+ return process.platform === 'win32' ? 'server.exe' : 'server';
151
+ }
152
+
153
+ /**
154
+ * Find csharp-lsp binary path
155
+ */
156
+ function findLspBinary() {
157
+ const rid = detectRid();
158
+ const exe = getExecutableName();
159
+
160
+ // Single location: ~/.unity/tools/csharp-lsp/<rid>/server
161
+ const candidates = [path.join(os.homedir(), '.unity', 'tools', 'csharp-lsp', rid, exe)];
162
+
163
+ for (const p of candidates) {
164
+ try {
165
+ const resolved = path.resolve(p);
166
+ if (fs.existsSync(resolved)) {
167
+ return resolved;
168
+ }
169
+ } catch {
170
+ // Ignore
171
+ }
172
+ }
173
+
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Worker-local LSP client class
179
+ * Manages its own csharp-lsp process independently from main thread
180
+ */
181
+ class WorkerLspClient {
182
+ constructor(projectRoot) {
183
+ this.projectRoot = projectRoot;
184
+ this.proc = null;
185
+ this.seq = 1;
186
+ this.pending = new Map();
187
+ this.buf = Buffer.alloc(0);
188
+ this.initialized = false;
189
+ }
190
+
191
+ async start() {
192
+ const binPath = findLspBinary();
193
+ if (!binPath) {
194
+ throw new Error(
195
+ `csharp-lsp binary not found. Expected at ~/.unity/tools/csharp-lsp/${detectRid()}/${getExecutableName()}`
196
+ );
197
+ }
198
+
199
+ log('info', `[worker-lsp] Starting LSP: ${binPath}`);
200
+
201
+ this.proc = spawn(binPath, [], { stdio: ['pipe', 'pipe', 'pipe'] });
202
+
203
+ this.proc.on('error', e => {
204
+ log('error', `[worker-lsp] Process error: ${e.message}`);
205
+ });
206
+
207
+ this.proc.on('close', (code, sig) => {
208
+ log('info', `[worker-lsp] Process exited: code=${code}, signal=${sig || 'none'}`);
209
+ // Reject all pending requests
210
+ for (const [id, p] of this.pending.entries()) {
211
+ p.reject(new Error('LSP process exited'));
212
+ this.pending.delete(id);
213
+ }
214
+ this.proc = null;
215
+ this.initialized = false;
216
+ });
217
+
218
+ this.proc.stderr.on('data', d => {
219
+ const s = String(d || '').trim();
220
+ if (s) log('debug', `[worker-lsp] stderr: ${s}`);
221
+ });
222
+
223
+ this.proc.stdout.on('data', chunk => this._onData(chunk));
224
+
225
+ // Initialize LSP
226
+ await this._initialize();
227
+ this.initialized = true;
228
+ log('info', `[worker-lsp] Initialized successfully`);
229
+ }
230
+
231
+ _onData(chunk) {
232
+ this.buf = Buffer.concat([this.buf, Buffer.from(chunk)]);
233
+ while (true) {
234
+ const headerEnd = this.buf.indexOf('\r\n\r\n');
235
+ if (headerEnd < 0) break;
236
+ const header = this.buf.slice(0, headerEnd).toString('utf8');
237
+ const m = header.match(/Content-Length:\s*(\d+)/i);
238
+ const len = m ? parseInt(m[1], 10) : 0;
239
+ const total = headerEnd + 4 + len;
240
+ if (this.buf.length < total) break;
241
+ const jsonBuf = this.buf.slice(headerEnd + 4, total);
242
+ this.buf = this.buf.slice(total);
243
+ try {
244
+ const msg = JSON.parse(jsonBuf.toString('utf8'));
245
+ if (msg.id && this.pending.has(msg.id)) {
246
+ this.pending.get(msg.id).resolve(msg);
247
+ this.pending.delete(msg.id);
248
+ }
249
+ } catch {
250
+ // Ignore parse errors
251
+ }
252
+ }
253
+ }
254
+
255
+ _writeMessage(obj) {
256
+ if (!this.proc || this.proc.killed) {
257
+ throw new Error('LSP process not available');
258
+ }
259
+ const json = JSON.stringify(obj);
260
+ const payload = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
261
+ this.proc.stdin.write(payload, 'utf8');
262
+ }
263
+
264
+ async _initialize() {
265
+ const id = this.seq++;
266
+ const req = {
267
+ jsonrpc: '2.0',
268
+ id,
269
+ method: 'initialize',
270
+ params: {
271
+ processId: process.pid,
272
+ rootUri: 'file://' + String(this.projectRoot).replace(/\\/g, '/'),
273
+ capabilities: {},
274
+ workspaceFolders: null
275
+ }
276
+ };
277
+
278
+ const timeoutMs = 60000;
279
+ const p = new Promise((resolve, reject) => {
280
+ this.pending.set(id, { resolve, reject });
281
+ setTimeout(() => {
282
+ if (this.pending.has(id)) {
283
+ this.pending.delete(id);
284
+ reject(new Error(`initialize timed out after ${timeoutMs}ms`));
285
+ }
286
+ }, timeoutMs);
287
+ });
288
+
289
+ this._writeMessage(req);
290
+ await p;
291
+
292
+ // Send initialized notification
293
+ this._writeMessage({ jsonrpc: '2.0', method: 'initialized', params: {} });
294
+ }
295
+
296
+ async request(method, params) {
297
+ if (!this.proc || this.proc.killed) {
298
+ throw new Error('LSP process not available');
299
+ }
300
+
301
+ const id = this.seq++;
302
+ const timeoutMs = 30000;
303
+
304
+ const p = new Promise((resolve, reject) => {
305
+ this.pending.set(id, { resolve, reject });
306
+ setTimeout(() => {
307
+ if (this.pending.has(id)) {
308
+ this.pending.delete(id);
309
+ reject(new Error(`${method} timed out after ${timeoutMs}ms`));
310
+ }
311
+ }, timeoutMs);
312
+ });
313
+
314
+ this._writeMessage({ jsonrpc: '2.0', id, method, params });
315
+ const resp = await p;
316
+ return resp?.result ?? resp;
317
+ }
318
+
319
+ stop() {
320
+ if (this.proc && !this.proc.killed) {
321
+ try {
322
+ // Send shutdown/exit
323
+ this._writeMessage({ jsonrpc: '2.0', id: this.seq++, method: 'shutdown', params: {} });
324
+ this._writeMessage({ jsonrpc: '2.0', method: 'exit' });
325
+ this.proc.stdin.end();
326
+ } catch {
327
+ // Ignore
328
+ }
329
+ setTimeout(() => {
330
+ if (this.proc && !this.proc.killed) {
331
+ this.proc.kill('SIGTERM');
332
+ }
333
+ }, 1000);
334
+ }
335
+ }
336
+ }
337
+
338
+ // ============================================================================
339
+ // End Worker-local LSP Client
340
+ // ============================================================================
341
+
126
342
  /**
127
343
  * Convert LSP symbol kind to string
128
344
  */
@@ -291,15 +507,23 @@ async function runBuild() {
291
507
 
292
508
  log('info', `[worker] Changes: ${changed.length} to update, ${removed.length} to remove`);
293
509
 
294
- // Remove vanished files
295
- for (const rel of removed) {
296
- const stmt1 = db.prepare('DELETE FROM symbols WHERE path = ?');
297
- stmt1.run([rel]);
298
- stmt1.free();
299
-
300
- const stmt2 = db.prepare('DELETE FROM files WHERE path = ?');
301
- stmt2.run([rel]);
302
- stmt2.free();
510
+ // Remove vanished files - optimized batch delete (Phase 2.2)
511
+ if (removed.length > 0) {
512
+ // SQLite has a limit on compound expressions, batch in groups of 500
513
+ const BATCH_SIZE = 500;
514
+ for (let i = 0; i < removed.length; i += BATCH_SIZE) {
515
+ const batch = removed.slice(i, i + BATCH_SIZE);
516
+ const placeholders = batch.map(() => '?').join(',');
517
+
518
+ const stmt1 = db.prepare(`DELETE FROM symbols WHERE path IN (${placeholders})`);
519
+ stmt1.run(batch);
520
+ stmt1.free();
521
+
522
+ const stmt2 = db.prepare(`DELETE FROM files WHERE path IN (${placeholders})`);
523
+ stmt2.run(batch);
524
+ stmt2.free();
525
+ }
526
+ log('info', `[worker] Removed ${removed.length} files in batches`);
303
527
  }
304
528
 
305
529
  // Prepare for updates
@@ -309,14 +533,12 @@ async function runBuild() {
309
533
  let updated = 0;
310
534
  let lastReportedPercentage = 0;
311
535
 
312
- // Initialize LSP connection
313
- // Note: LspRpcClientSingleton is in src/lsp/, not src/core/lsp/
314
- let lsp = null;
536
+ // Initialize Worker-local LSP client
537
+ // This is independent from main thread's LspRpcClientSingleton
538
+ const lsp = new WorkerLspClient(projectRoot);
315
539
  try {
316
- const lspModulePath = path.join(__dirname, '..', '..', 'lsp', 'LspRpcClientSingleton.js');
317
- const { LspRpcClientSingleton } = await import(`file://${lspModulePath}`);
318
- lsp = await LspRpcClientSingleton.getInstance(projectRoot);
319
- log('info', `[worker] LSP initialized`);
540
+ await lsp.start();
541
+ log('info', `[worker] Worker-local LSP initialized`);
320
542
  } catch (e) {
321
543
  throw new Error(`LSP initialization failed: ${e.message}`);
322
544
  }
@@ -327,7 +549,7 @@ async function runBuild() {
327
549
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
328
550
  try {
329
551
  const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
330
- return res?.result ?? res;
552
+ return res;
331
553
  } catch (err) {
332
554
  lastErr = err;
333
555
  await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
@@ -336,7 +558,57 @@ async function runBuild() {
336
558
  throw lastErr || new Error('documentSymbol failed');
337
559
  };
338
560
 
339
- // Process files sequentially (concurrency=1 for non-blocking)
561
+ // Phase 2.1 & 2.3: Process files with batched transactions
562
+ // Collect LSP results, then write in batched transactions to reduce overhead
563
+ const TX_BATCH_SIZE = 100; // Files per transaction
564
+ let pendingWrites = []; // { rel, rows, sig }
565
+
566
+ const flushPendingWrites = () => {
567
+ if (pendingWrites.length === 0) return;
568
+
569
+ runSQL(db, 'BEGIN TRANSACTION');
570
+ try {
571
+ // Batch delete old symbols for all pending files
572
+ const pathsToDelete = pendingWrites.map(w => w.rel);
573
+ const delPlaceholders = pathsToDelete.map(() => '?').join(',');
574
+ const delStmt = db.prepare(`DELETE FROM symbols WHERE path IN (${delPlaceholders})`);
575
+ delStmt.run(pathsToDelete);
576
+ delStmt.free();
577
+
578
+ // Batch insert all symbols
579
+ const insertStmt = db.prepare(
580
+ 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
581
+ );
582
+ for (const { rows } of pendingWrites) {
583
+ for (const r of rows) {
584
+ insertStmt.run([r.path, r.name, r.kind, r.container, r.ns, r.line, r.column]);
585
+ }
586
+ }
587
+ insertStmt.free();
588
+
589
+ // Batch update file signatures
590
+ const fileStmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
591
+ const now = new Date().toISOString();
592
+ for (const { rel, sig } of pendingWrites) {
593
+ fileStmt.run([rel, sig, now]);
594
+ }
595
+ fileStmt.free();
596
+
597
+ // Update meta once per batch
598
+ const metaStmt = db.prepare("REPLACE INTO meta(key,value) VALUES ('lastIndexedAt',?)");
599
+ metaStmt.run([now]);
600
+ metaStmt.free();
601
+
602
+ runSQL(db, 'COMMIT');
603
+ updated += pendingWrites.length;
604
+ } catch (txErr) {
605
+ runSQL(db, 'ROLLBACK');
606
+ log('error', `[worker] Batch write failed: ${txErr.message}`);
607
+ }
608
+ pendingWrites = [];
609
+ };
610
+
611
+ // Process files sequentially (LSP requests), batch DB writes
340
612
  for (let i = 0; i < absList.length; i++) {
341
613
  const abs = absList[i];
342
614
  const rel = toRel(abs, projectRoot);
@@ -346,36 +618,13 @@ async function runBuild() {
346
618
  const docSymbols = await requestWithRetry(uri);
347
619
  const rows = toRows(uri, docSymbols, projectRoot);
348
620
 
349
- // Update database in transaction
350
- runSQL(db, 'BEGIN TRANSACTION');
351
- try {
352
- const delStmt = db.prepare('DELETE FROM symbols WHERE path = ?');
353
- delStmt.run([rel]);
354
- delStmt.free();
355
-
356
- const insertStmt = db.prepare(
357
- 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
358
- );
359
- for (const r of rows) {
360
- insertStmt.run([r.path, r.name, r.kind, r.container, r.ns, r.line, r.column]);
361
- }
362
- insertStmt.free();
363
-
364
- const fileStmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
365
- fileStmt.run([rel, wanted.get(rel), new Date().toISOString()]);
366
- fileStmt.free();
367
-
368
- const metaStmt = db.prepare("REPLACE INTO meta(key,value) VALUES ('lastIndexedAt',?)");
369
- metaStmt.run([new Date().toISOString()]);
370
- metaStmt.free();
621
+ // Queue for batch write
622
+ pendingWrites.push({ rel, rows, sig: wanted.get(rel) });
371
623
 
372
- runSQL(db, 'COMMIT');
373
- } catch (txErr) {
374
- runSQL(db, 'ROLLBACK');
375
- throw txErr;
624
+ // Flush when batch is full
625
+ if (pendingWrites.length >= TX_BATCH_SIZE) {
626
+ flushPendingWrites();
376
627
  }
377
-
378
- updated++;
379
628
  } catch (err) {
380
629
  // Log occasionally to avoid spam
381
630
  if (processed % 50 === 0) {
@@ -408,6 +657,12 @@ async function runBuild() {
408
657
  }
409
658
  }
410
659
 
660
+ // Flush remaining pending writes
661
+ flushPendingWrites();
662
+
663
+ // Stop LSP process
664
+ lsp.stop();
665
+
411
666
  // Save database to file
412
667
  saveDatabase(db, dbPath);
413
668
 
@@ -8,7 +8,7 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
8
8
  constructor(unityConnection) {
9
9
  super(
10
10
  'code_index_build',
11
- 'Build (or rebuild) the persistent SQLite symbol index by scanning document symbols via the C# LSP. Returns immediately with jobId for background execution. Check progress with code_index_status. Stores DB under .unity/cache/code-index/code-index.db.',
11
+ '[OFFLINE] No Unity connection required. Build (or rebuild) the persistent SQLite symbol index by scanning document symbols via the C# LSP. Returns immediately with jobId for background execution. Check progress with code_index_status. Stores DB under .unity/cache/code-index/code-index.db.',
12
12
  {
13
13
  type: 'object',
14
14
  properties: {
@@ -6,7 +6,7 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
6
6
  constructor(unityConnection) {
7
7
  super(
8
8
  'code_index_status',
9
- 'Report code index status and readiness for symbol/search operations. BEST PRACTICES: Check before heavy symbol operations. Shows total files indexed and coverage percentage. If coverage is low, some symbol operations may be incomplete. Index is automatically built on first use. No parameters needed - lightweight status check.',
9
+ '[OFFLINE] No Unity connection required. Report code index status and readiness for symbol/search operations. BEST PRACTICES: Check before heavy symbol operations. Shows total files indexed and coverage percentage. If coverage is low, some symbol operations may be incomplete. Index is automatically built on first use. No parameters needed - lightweight status check.',
10
10
  {
11
11
  type: 'object',
12
12
  properties: {},
@@ -17,7 +17,7 @@ export class CodeIndexUpdateToolHandler extends BaseToolHandler {
17
17
  constructor(unityConnection) {
18
18
  super(
19
19
  'code_index_update',
20
- 'Refresh code index entries for specific C# files. Use this after modifying files so script editing tools see the latest symbols.',
20
+ '[OFFLINE] No Unity connection required. Refresh code index entries for specific C# files. Use this after modifying files so script editing tools see the latest symbols.',
21
21
  {
22
22
  type: 'object',
23
23
  properties: {
@@ -8,7 +8,7 @@ export class ScriptPackagesListToolHandler extends BaseToolHandler {
8
8
  constructor(unityConnection) {
9
9
  super(
10
10
  'script_packages_list',
11
- 'List Unity packages in the project (optionally include built‑in). BEST PRACTICES: Use to discover available packages and their paths. Set includeBuiltIn=false to see only user packages. Returns package IDs, versions, and resolved paths. Embedded packages can be edited directly. Essential for understanding project dependencies.',
11
+ '[OFFLINE] No Unity connection required. List Unity packages in the project (optionally include built‑in). BEST PRACTICES: Use to discover available packages and their paths. Set includeBuiltIn=false to see only user packages. Returns package IDs, versions, and resolved paths. Embedded packages can be edited directly. Essential for understanding project dependencies.',
12
12
  {
13
13
  type: 'object',
14
14
  properties: {
@@ -7,7 +7,7 @@ export class ScriptReadToolHandler extends BaseToolHandler {
7
7
  constructor(unityConnection) {
8
8
  super(
9
9
  'script_read',
10
- 'Read a C# file with optional line range and payload limits. Files must be under Assets/ or Packages/ and have .cs extension. PRIORITY: Read minimally — locate the target with script_symbols_get and read only the signature area (~30–40 lines). For large files, always pass startLine/endLine and (optionally) maxBytes.',
10
+ '[OFFLINE] No Unity connection required. Read a C# file with optional line range and payload limits. Files must be under Assets/ or Packages/ and have .cs extension. PRIORITY: Read minimally — locate the target with script_symbols_get and read only the signature area (~30–40 lines). For large files, always pass startLine/endLine and (optionally) maxBytes.',
11
11
  {
12
12
  type: 'object',
13
13
  properties: {
@@ -79,7 +79,14 @@ export class ScriptReadToolHandler extends BaseToolHandler {
79
79
 
80
80
  const abs = info.projectRoot + '/' + norm;
81
81
  const stat = await fs.stat(abs).catch(() => null);
82
- if (!stat || !stat.isFile()) return { error: 'File not found', path: norm };
82
+ if (!stat || !stat.isFile()) {
83
+ return {
84
+ error: 'File not found',
85
+ path: norm,
86
+ resolvedPath: abs,
87
+ hint: `Verify the file exists at: ${abs}. Path must be relative to Unity project root (e.g., "Assets/Scripts/Foo.cs" or "Packages/com.example/Runtime/Bar.cs").`
88
+ };
89
+ }
83
90
 
84
91
  const data = await fs.readFile(abs, 'utf8');
85
92
  const lines = data.split('\n');