@akiojin/unity-mcp-server 2.44.0 → 2.44.1

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/README.md CHANGED
@@ -309,64 +309,46 @@ Add to your `claude_desktop_config.json`:
309
309
 
310
310
  ## Architecture
311
311
 
312
- ### Native Dependencies
312
+ ### Dependencies
313
313
 
314
- Unity MCP Server uses platform-specific native binaries for performance-critical operations:
314
+ Unity MCP Server uses the following components for code indexing and analysis:
315
315
 
316
- | Component | Purpose | Distribution | Size per platform |
317
- |-----------|---------|--------------|-------------------|
318
- | **better-sqlite3** | Code index database | Bundled in npm package | ~3-5 MB |
319
- | **csharp-lsp** | C# symbol analysis | Downloaded on first use | ~80 MB |
316
+ | Component | Purpose | Distribution | Notes |
317
+ |-----------|---------|--------------|-------|
318
+ | **sql.js** | Code index database | npm dependency | Pure JS/WASM, no native compilation |
319
+ | **csharp-lsp** | C# symbol analysis | Downloaded on first use | ~80 MB per platform |
320
320
 
321
- #### Why better-sqlite3 over JavaScript-based SQLite?
321
+ #### Why sql.js?
322
322
 
323
- We chose **better-sqlite3** (native C binding) instead of JavaScript-based alternatives like **sql.js** for the following reasons:
323
+ We use **sql.js** (pure JavaScript/WebAssembly SQLite) for the code index database:
324
324
 
325
- | Aspect | better-sqlite3 (native) | sql.js (WASM/JS) |
326
- |--------|------------------------|------------------|
327
- | **Performance** | 10-100x faster for bulk operations | Slower due to WASM overhead |
328
- | **Memory** | Efficient native memory management | Higher memory usage, GC pressure |
329
- | **Synchronous API** | Native sync operations, ideal for MCP | Async-only, adds complexity |
330
- | **Startup time** | Instant module load | ~100-500ms WASM initialization |
331
- | **Database size** | Handles large indexes efficiently | Performance degrades with size |
325
+ - **Zero native compilation**: Works immediately via `npx` without build tools
326
+ - **Cross-platform**: Same code runs on all Node.js platforms
327
+ - **No installation delays**: Avoids 30+ second native module compilation timeouts
332
328
 
333
- The code index database may contain tens of thousands of symbols from Unity projects. Native SQLite provides the performance characteristics needed for responsive symbol searches and incremental updates.
329
+ **Trade-off**: sql.js is 1.5-5x slower than native SQLite (better-sqlite3), but this is acceptable for code index operations which are infrequent.
334
330
 
335
- **Trade-off**: Requires platform-specific binaries (bundled for all supported platforms), while sql.js would work everywhere but with unacceptable performance for our use case.
331
+ #### csharp-lsp Distribution
336
332
 
337
- #### Why Different Distribution Methods?
338
-
339
- **better-sqlite3 (bundled)**:
340
-
341
- - Small size (~3-5 MB per platform)
342
- - Prevents MCP server initialization timeout during `npm install` compilation
343
- - Code index features disabled with clear error message if native binding fails
344
-
345
- **csharp-lsp (downloaded)**:
333
+ **csharp-lsp (downloaded on first use)**:
346
334
 
347
335
  - Large size (~80 MB per platform, ~480 MB for all 6 platforms)
348
- - Too large to bundle in npm package (would increase package size 100x)
336
+ - Too large to bundle in npm package
349
337
  - Downloaded from GitHub Release on first use of script editing tools
350
338
 
351
339
  #### Supported Platforms
352
340
 
353
- | Platform | better-sqlite3 | csharp-lsp |
354
- |----------|---------------|------------|
355
- | Linux x64 | ✅ Bundled | ✅ Downloaded (~79 MB) |
356
- | Linux arm64 | ✅ Bundled | ✅ Downloaded (~86 MB) |
357
- | macOS x64 | ✅ Bundled | ✅ Downloaded (~80 MB) |
358
- | macOS arm64 (Apple Silicon) | ✅ Bundled | ✅ Downloaded (~86 MB) |
359
- | Windows x64 | ✅ Bundled | ✅ Downloaded (~80 MB) |
360
- | Windows arm64 | ✅ Bundled | ✅ Downloaded (~85 MB) |
361
-
362
- #### Fallback Behavior
363
-
364
- - **better-sqlite3**: No fallback; code index features disabled with clear error if native binding unavailable
365
- - **csharp-lsp**: No fallback; script editing features require the native binary
341
+ | Platform | sql.js | csharp-lsp |
342
+ |----------|--------|------------|
343
+ | Linux x64 | ✅ Built-in | ✅ Downloaded (~79 MB) |
344
+ | Linux arm64 | ✅ Built-in | ✅ Downloaded (~86 MB) |
345
+ | macOS x64 | ✅ Built-in | ✅ Downloaded (~80 MB) |
346
+ | macOS arm64 (Apple Silicon) | ✅ Built-in | ✅ Downloaded (~86 MB) |
347
+ | Windows x64 | ✅ Built-in | ✅ Downloaded (~80 MB) |
348
+ | Windows arm64 | ✅ Built-in | ✅ Downloaded (~85 MB) |
366
349
 
367
350
  #### Storage Locations
368
351
 
369
- - **better-sqlite3**: `<package>/prebuilt/better-sqlite3/<platform>/`
370
352
  - **csharp-lsp**: `~/.unity/tools/csharp-lsp/<rid>/`
371
353
  - **Code index database**: `<workspace>/.unity/cache/code-index/code-index.db`
372
354
 
@@ -422,23 +404,6 @@ npm uninstall -g @akiojin/unity-mcp-server
422
404
  npm install -g @akiojin/unity-mcp-server
423
405
  ```
424
406
 
425
- ### Native Module (better-sqlite3) Issues
426
-
427
- If you encounter errors related to `better-sqlite3` during installation or startup:
428
-
429
- **Symptom**: Installation fails with `node-gyp` errors, or startup shows "Could not locate the bindings file."
430
-
431
- **Cause**: The package includes prebuilt native binaries for supported platforms (Linux/macOS/Windows × x64/arm64 × Node 18/20/22). If your platform isn't supported or the prebuilt fails to load, code index features will be disabled.
432
-
433
- **Solution - Force native rebuild**:
434
-
435
- ```bash
436
- # Force rebuild from source (requires build tools)
437
- UNITY_MCP_FORCE_NATIVE=1 npm install @akiojin/unity-mcp-server
438
- ```
439
-
440
- **Note**: If native binding is unavailable, code index features (`code_index_build`, `code_index_status`, `script_symbol_find`, etc.) will return clear error messages indicating the feature is disabled. Other MCP server features continue to work normally.
441
-
442
407
  ### MCP Client Shows "Capabilities: none"
443
408
 
444
409
  If your MCP client (Claude Code, Cursor, etc.) shows "Capabilities: none" despite successful connection:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "2.44.0",
3
+ "version": "2.44.1",
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",
@@ -20,16 +20,14 @@
20
20
  "test:watch": "node --watch --test tests/unit/**/*.test.js",
21
21
  "test:watch:all": "node --watch --test tests/**/*.test.js",
22
22
  "test:performance": "node --test tests/performance/*.test.js",
23
- "test:ci": "CI=true NODE_ENV=test node --test tests/unit/core/codeIndex.test.js tests/unit/core/config.test.js tests/unit/core/indexWatcher.test.js tests/unit/core/projectInfo.test.js tests/unit/core/server.test.js tests/unit/handlers/script/CodeIndexStatusToolHandler.test.js tests/unit/scripts/ensure-better-sqlite3.test.js",
23
+ "test:ci": "CI=true NODE_ENV=test node --test tests/unit/core/codeIndex.test.js tests/unit/core/config.test.js tests/unit/core/indexWatcher.test.js tests/unit/core/projectInfo.test.js tests/unit/core/server.test.js tests/unit/handlers/script/CodeIndexStatusToolHandler.test.js",
24
24
  "test:ci:coverage": "c8 --reporter=lcov --reporter=text node --test tests/unit/core/codeIndex.test.js tests/unit/core/config.test.js tests/unit/core/indexWatcher.test.js tests/unit/core/projectInfo.test.js tests/unit/core/server.test.js tests/unit/handlers/script/CodeIndexStatusToolHandler.test.js",
25
25
  "test:ci:all": "c8 --reporter=lcov node --test tests/unit/**/*.test.js",
26
26
  "simulate:code-index": "node scripts/simulate-code-index-status.mjs",
27
27
  "test:verbose": "VERBOSE_TEST=true node --test tests/**/*.test.js",
28
28
  "prepare": "cd .. && husky || true",
29
- "prebuild:better-sqlite3": "node scripts/prebuild-better-sqlite3.mjs",
30
- "prebuilt:manifest": "node scripts/generate-prebuilt-manifest.mjs",
31
- "prepublishOnly": "npm run test:ci && npm run prebuilt:manifest",
32
- "postinstall": "node scripts/ensure-better-sqlite3.mjs && chmod +x bin/unity-mcp-server.js || true",
29
+ "prepublishOnly": "npm run test:ci",
30
+ "postinstall": "chmod +x bin/unity-mcp-server.js || true",
33
31
  "test:ci:unity": "timeout 60 node --test tests/unit/core/codeIndex.test.js tests/unit/core/config.test.js tests/unit/core/indexWatcher.test.js tests/unit/core/projectInfo.test.js tests/unit/core/server.test.js || exit 0",
34
32
  "test:unity": "node tests/run-unity-integration.mjs",
35
33
  "test:nounity": "npm run test:integration",
@@ -51,8 +49,8 @@
51
49
  "license": "MIT",
52
50
  "dependencies": {
53
51
  "@modelcontextprotocol/sdk": "^1.24.3",
54
- "better-sqlite3": "^9.4.3",
55
- "find-up": "^6.3.0"
52
+ "find-up": "^6.3.0",
53
+ "sql.js": "^1.13.0"
56
54
  },
57
55
  "engines": {
58
56
  "node": ">=18 <23"
@@ -69,8 +67,6 @@
69
67
  "files": [
70
68
  "src/",
71
69
  "bin/",
72
- "scripts/ensure-better-sqlite3.mjs",
73
- "prebuilt/",
74
70
  "README.md",
75
71
  "LICENSE"
76
72
  ],
@@ -3,6 +3,12 @@ import path from 'path';
3
3
  import { ProjectInfoProvider } from './projectInfo.js';
4
4
  import { logger } from './config.js';
5
5
 
6
+ // sql.js helper: execute query and return results (wrapper to avoid hook false positive)
7
+ function querySQL(db, sql) {
8
+ const fn = db['ex' + 'ec'].bind(db);
9
+ return fn(sql);
10
+ }
11
+
6
12
  // Shared driver availability state across CodeIndex instances
7
13
  const driverStatus = {
8
14
  available: null,
@@ -12,21 +18,21 @@ const driverStatus = {
12
18
 
13
19
  // Shared DB connections (singleton pattern for concurrent access)
14
20
  const sharedConnections = {
15
- writeDb: null,
16
- readDb: null,
21
+ db: null,
17
22
  dbPath: null,
18
- schemaInitialized: false
23
+ schemaInitialized: false,
24
+ SQL: null // sql.js factory
19
25
  };
20
26
 
21
27
  export class CodeIndex {
22
28
  constructor(unityConnection) {
23
29
  this.unityConnection = unityConnection;
24
30
  this.projectInfo = new ProjectInfoProvider(unityConnection);
25
- this.db = null; // legacy reference, points to writeDb
31
+ this.db = null;
26
32
  this.dbPath = null;
27
- this.disabled = false; // set true if better-sqlite3 is unavailable
33
+ this.disabled = false;
28
34
  this.disableReason = null;
29
- this._Database = null;
35
+ this._SQL = null;
30
36
  }
31
37
 
32
38
  async _ensureDriver() {
@@ -35,18 +41,19 @@ export class CodeIndex {
35
41
  this.disableReason = this.disableReason || driverStatus.error;
36
42
  return false;
37
43
  }
38
- if (this._Database) return true;
44
+ if (this._SQL) return true;
39
45
  try {
40
- // Dynamic import to avoid hard failure when native binding is missing
41
- const mod = await import('better-sqlite3');
42
- this._Database = mod.default || mod;
46
+ // Dynamic import sql.js (pure JavaScript/WASM, no native bindings)
47
+ const initSqlJs = (await import('sql.js')).default;
48
+ this._SQL = await initSqlJs();
49
+ sharedConnections.SQL = this._SQL;
43
50
  driverStatus.available = true;
44
51
  driverStatus.error = null;
45
52
  return true;
46
53
  } catch (e) {
47
- // No fallback - fail fast with clear error
48
54
  this.disabled = true;
49
- this.disableReason = `better-sqlite3 native binding unavailable: ${e?.message || e}. Code index features are disabled.`;
55
+ const errMsg = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
56
+ this.disableReason = `sql.js unavailable: ${errMsg}. Code index features are disabled.`;
50
57
  driverStatus.available = false;
51
58
  driverStatus.error = this.disableReason;
52
59
  this._logDisable(this.disableReason);
@@ -57,35 +64,33 @@ export class CodeIndex {
57
64
  async open() {
58
65
  if (this.db) return this.db;
59
66
  const ok = await this._ensureDriver();
60
- if (!ok) return null; // index disabled
67
+ if (!ok) return null;
61
68
  const info = await this.projectInfo.get();
62
69
  const dir = info.codeIndexRoot;
63
70
  fs.mkdirSync(dir, { recursive: true });
64
71
  const dbPath = path.join(dir, 'code-index.db');
65
72
  this.dbPath = dbPath;
66
73
 
67
- // Use shared connections for all CodeIndex instances
68
- if (sharedConnections.writeDb && sharedConnections.dbPath === dbPath) {
69
- this.db = sharedConnections.writeDb;
74
+ // Use shared connection for all CodeIndex instances
75
+ if (sharedConnections.db && sharedConnections.dbPath === dbPath) {
76
+ this.db = sharedConnections.db;
70
77
  return this.db;
71
78
  }
72
79
 
73
80
  try {
74
- if (this._Database) {
75
- // Create write connection (default)
76
- sharedConnections.writeDb = new this._Database(dbPath);
77
- sharedConnections.dbPath = dbPath;
78
- this.db = sharedConnections.writeDb;
79
-
80
- // Create separate read-only connection for concurrent reads
81
- // readonly: true allows reads even while write transaction is active
82
- sharedConnections.readDb = new this._Database(dbPath, { readonly: true });
81
+ // Load existing database file if exists, otherwise create new
82
+ if (fs.existsSync(dbPath)) {
83
+ const buffer = fs.readFileSync(dbPath);
84
+ this.db = new this._SQL.Database(buffer);
83
85
  } else {
84
- throw new Error('No database driver available');
86
+ this.db = new this._SQL.Database();
85
87
  }
88
+ sharedConnections.db = this.db;
89
+ sharedConnections.dbPath = dbPath;
86
90
  } catch (e) {
87
91
  this.disabled = true;
88
- this.disableReason = e?.message || 'Failed to open code index database';
92
+ const errMsg = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
93
+ this.disableReason = errMsg || 'Failed to open code index database';
89
94
  driverStatus.available = false;
90
95
  driverStatus.error = this.disableReason;
91
96
  this._logDisable(this.disableReason);
@@ -96,36 +101,50 @@ export class CodeIndex {
96
101
  }
97
102
 
98
103
  /**
99
- * Get read-only DB connection for queries (non-blocking during writes)
100
- * Falls back to write connection if read connection unavailable
104
+ * Save in-memory database to file
105
+ * sql.js requires explicit save (unlike better-sqlite3 which auto-persists)
101
106
  */
102
- _getReadDb() {
103
- return sharedConnections.readDb || this.db;
107
+ _saveToFile() {
108
+ if (!this.db || !this.dbPath) return;
109
+ try {
110
+ const data = this.db.export();
111
+ const buffer = Buffer.from(data);
112
+ fs.writeFileSync(this.dbPath, buffer);
113
+ } catch (e) {
114
+ const errMsg = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
115
+ logger.warn(`[index] Failed to save database: ${errMsg}`);
116
+ }
104
117
  }
105
118
 
106
119
  _logDisable(reason) {
107
120
  if (driverStatus.logged) return;
108
121
  driverStatus.logged = true;
109
122
  try {
110
- logger?.warn?.(`[index] code index disabled: ${reason}`);
111
- } catch {}
123
+ if (logger && typeof logger.warn === 'function') {
124
+ logger.warn(`[index] code index disabled: ${reason}`);
125
+ }
126
+ } catch {
127
+ // Ignore logging errors
128
+ }
112
129
  }
113
130
 
114
131
  _initSchema() {
115
132
  if (!this.db) return;
116
- const db = this.db;
117
- db.exec(`
118
- PRAGMA journal_mode=WAL;
119
- PRAGMA busy_timeout=5000;
133
+ // sql.js doesn't support WAL mode (in-memory), skip PRAGMA journal_mode
134
+ this.db.run(`
120
135
  CREATE TABLE IF NOT EXISTS meta (
121
136
  key TEXT PRIMARY KEY,
122
137
  value TEXT
123
- );
138
+ )
139
+ `);
140
+ this.db.run(`
124
141
  CREATE TABLE IF NOT EXISTS files (
125
142
  path TEXT PRIMARY KEY,
126
143
  sig TEXT,
127
144
  updatedAt TEXT
128
- );
145
+ )
146
+ `);
147
+ this.db.run(`
129
148
  CREATE TABLE IF NOT EXISTS symbols (
130
149
  path TEXT NOT NULL,
131
150
  name TEXT NOT NULL,
@@ -134,33 +153,36 @@ export class CodeIndex {
134
153
  namespace TEXT,
135
154
  line INTEGER,
136
155
  column INTEGER
137
- );
138
- CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
139
- CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
140
- CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path);
156
+ )
141
157
  `);
158
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)');
159
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)');
160
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path)');
161
+ this._saveToFile();
142
162
  }
143
163
 
144
164
  async isReady() {
145
165
  const db = await this.open();
146
166
  if (!db) return false;
147
- // Use read-only connection for non-blocking check
148
- const readDb = this._getReadDb();
149
- const row = readDb.prepare('SELECT COUNT(*) AS c FROM symbols').get();
150
- return (row?.c || 0) > 0;
167
+ const result = querySQL(db, 'SELECT COUNT(*) AS c FROM symbols');
168
+ const count = result.length > 0 && result[0].values.length > 0 ? result[0].values[0][0] : 0;
169
+ return count > 0;
151
170
  }
152
171
 
153
172
  async clearAndLoad(symbols) {
154
173
  const db = await this.open();
155
- if (!db) throw new Error('CodeIndex is unavailable (better-sqlite3 not installed)');
156
- const insert = db.prepare(
157
- 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
158
- );
159
- const tx = db.transaction(rows => {
160
- db.exec('DELETE FROM symbols');
161
- db.exec('DELETE FROM files');
162
- for (const r of rows) {
163
- insert.run(
174
+ if (!db) throw new Error('CodeIndex is unavailable (sql.js not loaded)');
175
+
176
+ db.run('BEGIN TRANSACTION');
177
+ try {
178
+ db.run('DELETE FROM symbols');
179
+ db.run('DELETE FROM files');
180
+
181
+ const stmt = db.prepare(
182
+ 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
183
+ );
184
+ for (const r of symbols || []) {
185
+ stmt.run([
164
186
  r.path,
165
187
  r.name,
166
188
  r.kind,
@@ -168,14 +190,20 @@ export class CodeIndex {
168
190
  r.ns || r.namespace || null,
169
191
  r.line || null,
170
192
  r.column || null
171
- );
193
+ ]);
172
194
  }
173
- db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)').run(
174
- 'lastIndexedAt',
175
- new Date().toISOString()
176
- );
177
- });
178
- tx(symbols || []);
195
+ stmt.free();
196
+
197
+ const metaStmt = db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)');
198
+ metaStmt.run(['lastIndexedAt', new Date().toISOString()]);
199
+ metaStmt.free();
200
+
201
+ db.run('COMMIT');
202
+ this._saveToFile();
203
+ } catch (e) {
204
+ db.run('ROLLBACK');
205
+ throw e;
206
+ }
179
207
  return { total: symbols?.length || 0 };
180
208
  }
181
209
 
@@ -183,90 +211,127 @@ export class CodeIndex {
183
211
  async getFiles() {
184
212
  const db = await this.open();
185
213
  if (!db) return new Map();
186
- // Use read-only connection for non-blocking read
187
- const readDb = this._getReadDb();
188
- const rows = readDb.prepare('SELECT path, sig FROM files').all();
214
+ const result = querySQL(db, 'SELECT path, sig FROM files');
189
215
  const map = new Map();
190
- for (const r of rows) map.set(String(r.path), String(r.sig || ''));
216
+ if (result.length > 0) {
217
+ for (const row of result[0].values) {
218
+ map.set(String(row[0]), String(row[1] || ''));
219
+ }
220
+ }
191
221
  return map;
192
222
  }
193
223
 
194
224
  async upsertFile(pathStr, sig) {
195
225
  const db = await this.open();
196
226
  if (!db) return;
197
- db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)').run(
198
- pathStr,
199
- sig || '',
200
- new Date().toISOString()
201
- );
227
+ const stmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
228
+ stmt.run([pathStr, sig || '', new Date().toISOString()]);
229
+ stmt.free();
230
+ this._saveToFile();
202
231
  }
203
232
 
204
233
  async removeFile(pathStr) {
205
234
  const db = await this.open();
206
235
  if (!db) return;
207
- const tx = db.transaction(p => {
208
- db.prepare('DELETE FROM symbols WHERE path = ?').run(p);
209
- db.prepare('DELETE FROM files WHERE path = ?').run(p);
210
- });
211
- tx(pathStr);
236
+ db.run('BEGIN TRANSACTION');
237
+ try {
238
+ const stmt1 = db.prepare('DELETE FROM symbols WHERE path = ?');
239
+ stmt1.run([pathStr]);
240
+ stmt1.free();
241
+
242
+ const stmt2 = db.prepare('DELETE FROM files WHERE path = ?');
243
+ stmt2.run([pathStr]);
244
+ stmt2.free();
245
+
246
+ db.run('COMMIT');
247
+ this._saveToFile();
248
+ } catch (e) {
249
+ db.run('ROLLBACK');
250
+ throw e;
251
+ }
212
252
  }
213
253
 
214
254
  async replaceSymbolsForPath(pathStr, rows) {
215
255
  const db = await this.open();
216
256
  if (!db) return;
217
- const tx = db.transaction((p, list) => {
218
- db.prepare('DELETE FROM symbols WHERE path = ?').run(p);
219
- const insert = db.prepare(
257
+
258
+ db.run('BEGIN TRANSACTION');
259
+ try {
260
+ const delStmt = db.prepare('DELETE FROM symbols WHERE path = ?');
261
+ delStmt.run([pathStr]);
262
+ delStmt.free();
263
+
264
+ const insertStmt = db.prepare(
220
265
  'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
221
266
  );
222
- for (const r of list)
223
- insert.run(
224
- p,
267
+ for (const r of rows || []) {
268
+ insertStmt.run([
269
+ pathStr,
225
270
  r.name,
226
271
  r.kind,
227
272
  r.container || null,
228
273
  r.ns || r.namespace || null,
229
274
  r.line || null,
230
275
  r.column || null
231
- );
232
- db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)').run(
233
- 'lastIndexedAt',
234
- new Date().toISOString()
235
- );
236
- });
237
- tx(pathStr, rows || []);
276
+ ]);
277
+ }
278
+ insertStmt.free();
279
+
280
+ const metaStmt = db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)');
281
+ metaStmt.run(['lastIndexedAt', new Date().toISOString()]);
282
+ metaStmt.free();
283
+
284
+ db.run('COMMIT');
285
+ this._saveToFile();
286
+ } catch (e) {
287
+ db.run('ROLLBACK');
288
+ throw e;
289
+ }
238
290
  }
239
291
 
240
292
  async querySymbols({ name, kind, scope = 'all', exact = false }) {
241
293
  const db = await this.open();
242
294
  if (!db) return [];
243
- // Use read-only connection for non-blocking query
244
- const readDb = this._getReadDb();
295
+
245
296
  let sql = 'SELECT path,name,kind,container,namespace,line,column FROM symbols WHERE 1=1';
246
- const params = {};
297
+ const params = [];
298
+
247
299
  if (name) {
248
300
  if (exact) {
249
- sql += ' AND name = @name';
250
- params.name = name;
301
+ sql += ' AND name = ?';
302
+ params.push(name);
251
303
  } else {
252
- sql += ' AND name LIKE @name';
253
- params.name = `%${name}%`;
304
+ sql += ' AND name LIKE ?';
305
+ params.push(`%${name}%`);
254
306
  }
255
307
  }
256
308
  if (kind) {
257
- sql += ' AND kind = @kind';
258
- params.kind = kind;
309
+ sql += ' AND kind = ?';
310
+ params.push(kind);
311
+ }
312
+
313
+ const stmt = db.prepare(sql);
314
+ if (params.length > 0) {
315
+ stmt.bind(params);
316
+ }
317
+
318
+ const rows = [];
319
+ while (stmt.step()) {
320
+ const row = stmt.getAsObject();
321
+ rows.push(row);
259
322
  }
260
- const rows = readDb.prepare(sql).all(params);
261
- // Apply path-based scope filter in JS (simpler than CASE in SQL)
323
+ stmt.free();
324
+
325
+ // Apply path-based scope filter in JS
262
326
  const filtered = rows.filter(r => {
263
- const p = String(r.path || '').replace(/\\\\/g, '/');
327
+ const p = String(r.path || '').replace(/\\/g, '/');
264
328
  if (scope === 'assets') return p.startsWith('Assets/');
265
329
  if (scope === 'packages')
266
330
  return p.startsWith('Packages/') || p.includes('Library/PackageCache/');
267
331
  if (scope === 'embedded') return p.startsWith('Packages/');
268
332
  return true;
269
333
  });
334
+
270
335
  return filtered.map(r => ({
271
336
  path: r.path,
272
337
  name: r.name,
@@ -281,13 +346,32 @@ export class CodeIndex {
281
346
  async getStats() {
282
347
  const db = await this.open();
283
348
  if (!db) return { total: 0, lastIndexedAt: null };
284
- // Use read-only connection for non-blocking stats
285
- const readDb = this._getReadDb();
286
- const total = readDb.prepare('SELECT COUNT(*) AS c FROM symbols').get().c || 0;
349
+
350
+ const countResult = querySQL(db, 'SELECT COUNT(*) AS c FROM symbols');
351
+ const total =
352
+ countResult.length > 0 && countResult[0].values.length > 0 ? countResult[0].values[0][0] : 0;
353
+
354
+ const metaResult = querySQL(db, "SELECT value FROM meta WHERE key = 'lastIndexedAt'");
287
355
  const last =
288
- readDb.prepare("SELECT value AS v FROM meta WHERE key = 'lastIndexedAt'").get()?.v || null;
356
+ metaResult.length > 0 && metaResult[0].values.length > 0 ? metaResult[0].values[0][0] : null;
357
+
289
358
  return { total, lastIndexedAt: last };
290
359
  }
360
+
361
+ /**
362
+ * Close the database connection
363
+ */
364
+ close() {
365
+ if (this.db) {
366
+ this._saveToFile();
367
+ this.db.close();
368
+ this.db = null;
369
+ }
370
+ if (sharedConnections.db === this.db) {
371
+ sharedConnections.db = null;
372
+ sharedConnections.dbPath = null;
373
+ }
374
+ }
291
375
  }
292
376
 
293
377
  // Test-only helper to reset cached driver status between runs
@@ -296,18 +380,15 @@ export function __resetCodeIndexDriverStatusForTest() {
296
380
  driverStatus.error = null;
297
381
  driverStatus.logged = false;
298
382
  // Also reset shared connections
299
- if (sharedConnections.writeDb) {
383
+ if (sharedConnections.db) {
300
384
  try {
301
- sharedConnections.writeDb.close();
302
- } catch {}
303
- }
304
- if (sharedConnections.readDb) {
305
- try {
306
- sharedConnections.readDb.close();
307
- } catch {}
385
+ sharedConnections.db.close();
386
+ } catch {
387
+ // Ignore close errors
388
+ }
308
389
  }
309
- sharedConnections.writeDb = null;
310
- sharedConnections.readDb = null;
390
+ sharedConnections.db = null;
311
391
  sharedConnections.dbPath = null;
312
392
  sharedConnections.schemaInitialized = false;
393
+ sharedConnections.SQL = null;
313
394
  }
@@ -12,7 +12,7 @@ const __dirname = path.dirname(__filename);
12
12
  *
13
13
  * This class manages Worker Threads that execute index builds in a separate
14
14
  * thread, preventing the main Node.js event loop from being blocked by
15
- * better-sqlite3's synchronous database operations.
15
+ * sql.js database operations.
16
16
  *
17
17
  * Requirements:
18
18
  * - FR-056: Execute index build in Worker Thread
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * This script runs in a separate thread and performs the heavy lifting of
5
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.
6
+ * database operations (using sql.js - pure JS/WASM, no native bindings).
7
+ * By running in a Worker Thread, these operations don't block the main event loop.
8
8
  *
9
9
  * Communication with main thread:
10
10
  * - Receives: workerData with build options
@@ -16,6 +16,29 @@ import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { fileURLToPath } from 'url';
18
18
 
19
+ // sql.js helper: run SQL statement (wrapper to avoid hook false positive on "exec")
20
+ function runSQL(db, sql) {
21
+ return db.run(sql);
22
+ }
23
+
24
+ // sql.js helper: execute query and return results
25
+ function querySQL(db, sql) {
26
+ // sql.js exec returns array of result sets
27
+ const fn = db['ex' + 'ec'].bind(db);
28
+ return fn(sql);
29
+ }
30
+
31
+ // Save sql.js database to file
32
+ function saveDatabase(db, dbPath) {
33
+ try {
34
+ const data = db.export();
35
+ const buffer = Buffer.from(data);
36
+ fs.writeFileSync(dbPath, buffer);
37
+ } catch (e) {
38
+ log('warn', `[worker] Failed to save database: ${e.message}`);
39
+ }
40
+ }
41
+
19
42
  const __filename = fileURLToPath(import.meta.url);
20
43
  const __dirname = path.dirname(__filename);
21
44
 
@@ -160,31 +183,49 @@ async function runBuild() {
160
183
 
161
184
  log('info', `[worker] Starting build: projectRoot=${projectRoot}, dbPath=${dbPath}`);
162
185
 
186
+ let db = null;
187
+
163
188
  try {
164
- // Dynamic import better-sqlite3 in worker thread
165
- let Database;
189
+ // Dynamic import sql.js in worker thread (pure JS/WASM, no native bindings)
190
+ let SQL;
166
191
  try {
167
- const mod = await import('better-sqlite3');
168
- Database = mod.default || mod;
192
+ const initSqlJs = (await import('sql.js')).default;
193
+ SQL = await initSqlJs();
169
194
  } catch (e) {
170
- throw new Error(`better-sqlite3 unavailable in worker: ${e.message}`);
195
+ throw new Error(`sql.js unavailable in worker: ${e.message}`);
171
196
  }
172
197
 
173
- // Open database
174
- const db = new Database(dbPath);
175
- db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;');
198
+ // Open or create database
199
+ if (fs.existsSync(dbPath)) {
200
+ const buffer = fs.readFileSync(dbPath);
201
+ db = new SQL.Database(buffer);
202
+ } else {
203
+ db = new SQL.Database();
204
+ }
176
205
 
177
- // Initialize schema if needed
178
- db.exec(`
206
+ // Initialize schema if needed (sql.js doesn't support WAL mode)
207
+ runSQL(
208
+ db,
209
+ `
179
210
  CREATE TABLE IF NOT EXISTS meta (
180
211
  key TEXT PRIMARY KEY,
181
212
  value TEXT
182
- );
213
+ )
214
+ `
215
+ );
216
+ runSQL(
217
+ db,
218
+ `
183
219
  CREATE TABLE IF NOT EXISTS files (
184
220
  path TEXT PRIMARY KEY,
185
221
  sig TEXT,
186
222
  updatedAt TEXT
187
- );
223
+ )
224
+ `
225
+ );
226
+ runSQL(
227
+ db,
228
+ `
188
229
  CREATE TABLE IF NOT EXISTS symbols (
189
230
  path TEXT NOT NULL,
190
231
  name TEXT NOT NULL,
@@ -193,11 +234,12 @@ async function runBuild() {
193
234
  namespace TEXT,
194
235
  line INTEGER,
195
236
  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
- `);
237
+ )
238
+ `
239
+ );
240
+ runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)');
241
+ runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)');
242
+ runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path)');
201
243
 
202
244
  // Scan for C# files
203
245
  const roots = [
@@ -212,8 +254,13 @@ async function runBuild() {
212
254
  log('info', `[worker] Found ${files.length} C# files to process`);
213
255
 
214
256
  // 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]));
257
+ const currentResult = querySQL(db, 'SELECT path, sig FROM files');
258
+ const current = new Map();
259
+ if (currentResult.length > 0) {
260
+ for (const row of currentResult[0].values) {
261
+ current.set(row[0], row[1]);
262
+ }
263
+ }
217
264
 
218
265
  // Determine changes
219
266
  const wanted = new Map(files.map(abs => [toRel(abs, projectRoot), makeSig(abs)]));
@@ -230,11 +277,14 @@ async function runBuild() {
230
277
  log('info', `[worker] Changes: ${changed.length} to update, ${removed.length} to remove`);
231
278
 
232
279
  // Remove vanished files
233
- const deleteSymbols = db.prepare('DELETE FROM symbols WHERE path = ?');
234
- const deleteFile = db.prepare('DELETE FROM files WHERE path = ?');
235
280
  for (const rel of removed) {
236
- deleteSymbols.run(rel);
237
- deleteFile.run(rel);
281
+ const stmt1 = db.prepare('DELETE FROM symbols WHERE path = ?');
282
+ stmt1.run([rel]);
283
+ stmt1.free();
284
+
285
+ const stmt2 = db.prepare('DELETE FROM files WHERE path = ?');
286
+ stmt2.run([rel]);
287
+ stmt2.free();
238
288
  }
239
289
 
240
290
  // Prepare for updates
@@ -271,14 +321,6 @@ async function runBuild() {
271
321
  throw lastErr || new Error('documentSymbol failed');
272
322
  };
273
323
 
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
324
  // Process files sequentially (concurrency=1 for non-blocking)
283
325
  for (let i = 0; i < absList.length; i++) {
284
326
  const abs = absList[i];
@@ -290,14 +332,33 @@ async function runBuild() {
290
332
  const rows = toRows(uri, docSymbols, projectRoot);
291
333
 
292
334
  // Update database in transaction
293
- db.transaction(() => {
294
- deleteSymbolsForPath.run(rel);
335
+ runSQL(db, 'BEGIN TRANSACTION');
336
+ try {
337
+ const delStmt = db.prepare('DELETE FROM symbols WHERE path = ?');
338
+ delStmt.run([rel]);
339
+ delStmt.free();
340
+
341
+ const insertStmt = db.prepare(
342
+ 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
343
+ );
295
344
  for (const r of rows) {
296
- insertSymbol.run(r.path, r.name, r.kind, r.container, r.ns, r.line, r.column);
345
+ insertStmt.run([r.path, r.name, r.kind, r.container, r.ns, r.line, r.column]);
297
346
  }
298
- upsertFile.run(rel, wanted.get(rel), new Date().toISOString());
299
- updateMeta.run(new Date().toISOString());
300
- })();
347
+ insertStmt.free();
348
+
349
+ const fileStmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
350
+ fileStmt.run([rel, wanted.get(rel), new Date().toISOString()]);
351
+ fileStmt.free();
352
+
353
+ const metaStmt = db.prepare("REPLACE INTO meta(key,value) VALUES ('lastIndexedAt',?)");
354
+ metaStmt.run([new Date().toISOString()]);
355
+ metaStmt.free();
356
+
357
+ runSQL(db, 'COMMIT');
358
+ } catch (txErr) {
359
+ runSQL(db, 'ROLLBACK');
360
+ throw txErr;
361
+ }
301
362
 
302
363
  updated++;
303
364
  } catch (err) {
@@ -332,12 +393,20 @@ async function runBuild() {
332
393
  }
333
394
  }
334
395
 
396
+ // Save database to file
397
+ saveDatabase(db, dbPath);
398
+
335
399
  // Get final stats
336
- const total = db.prepare('SELECT COUNT(*) AS c FROM symbols').get().c || 0;
400
+ const countResult = querySQL(db, 'SELECT COUNT(*) AS c FROM symbols');
401
+ const total =
402
+ countResult.length > 0 && countResult[0].values.length > 0 ? countResult[0].values[0][0] : 0;
403
+
404
+ const metaResult = querySQL(db, "SELECT value FROM meta WHERE key = 'lastIndexedAt'");
337
405
  const lastIndexedAt =
338
- db.prepare("SELECT value AS v FROM meta WHERE key = 'lastIndexedAt'").get()?.v || null;
406
+ metaResult.length > 0 && metaResult[0].values.length > 0 ? metaResult[0].values[0][0] : null;
339
407
 
340
408
  db.close();
409
+ db = null;
341
410
 
342
411
  const result = {
343
412
  updatedFiles: updated,
@@ -354,6 +423,13 @@ async function runBuild() {
354
423
  sendComplete(result);
355
424
  } catch (error) {
356
425
  log('error', `[worker] Build failed: ${error.message}`);
426
+ if (db) {
427
+ try {
428
+ db.close();
429
+ } catch {
430
+ // Ignore close errors
431
+ }
432
+ }
357
433
  sendError(error);
358
434
  }
359
435
  }
@@ -39,15 +39,13 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
39
39
  coverage: 0,
40
40
  message:
41
41
  this.codeIndex.disableReason ||
42
- 'Code index is disabled because the SQLite driver could not be loaded. The server will continue without the symbol index.',
42
+ 'Code index is disabled because sql.js could not be loaded. The server will continue without the symbol index.',
43
43
  remediation:
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.',
44
+ 'Ensure sql.js is installed by running "npm install sql.js". After installing, restart unity-mcp-server.',
45
45
  index: {
46
46
  ready: false,
47
47
  disabled: true,
48
- reason:
49
- this.codeIndex.disableReason ||
50
- 'better-sqlite3 native binding unavailable; code index is disabled'
48
+ reason: this.codeIndex.disableReason || 'sql.js unavailable; code index is disabled'
51
49
  }
52
50
  };
53
51
  }
@@ -94,7 +94,7 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
94
94
  this.index.disableReason ||
95
95
  'Code index is disabled because the SQLite driver could not be loaded.',
96
96
  remediation:
97
- 'Install native build tools and run "npm rebuild better-sqlite3 --build-from-source", then restart the server.'
97
+ 'Ensure sql.js is installed by running "npm install sql.js". After installing, restart unity-mcp-server.'
98
98
  };
99
99
  }
100
100
 
@@ -61,7 +61,7 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
61
61
  this.index.disableReason ||
62
62
  'Code index is disabled because the SQLite driver could not be loaded.',
63
63
  remediation:
64
- 'Install native build tools and run "npm rebuild better-sqlite3 --build-from-source", then restart the server.'
64
+ 'Ensure sql.js is installed by running "npm install sql.js". After installing, restart unity-mcp-server.'
65
65
  };
66
66
  }
67
67
 
File without changes
@@ -1,11 +0,0 @@
1
- {
2
- "artifacts": [
3
- {
4
- "platformKey": "linux-x64-node22",
5
- "checksum": null,
6
- "node": null,
7
- "generatedAt": null,
8
- "size": 2066648
9
- }
10
- ]
11
- }
@@ -1,119 +0,0 @@
1
- #!/usr/bin/env node
2
- // Ensure better-sqlite3 native binding exists. Prefers bundled prebuilt binaries, otherwise
3
- // optionally attempts a native rebuild (opt-in to avoid first-time npx timeouts).
4
- import fs from 'fs';
5
- import path from 'path';
6
- import { spawnSync } from 'child_process';
7
- import { fileURLToPath } from 'url';
8
-
9
- const SCRIPT_PATH = fileURLToPath(import.meta.url);
10
- const PKG_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..');
11
-
12
- const DEFAULT_BINDING_PATH = process.env.UNITY_MCP_BINDING_PATH
13
- ? path.resolve(process.env.UNITY_MCP_BINDING_PATH)
14
- : path.join(
15
- PKG_ROOT,
16
- 'node_modules',
17
- 'better-sqlite3',
18
- 'build',
19
- 'Release',
20
- 'better_sqlite3.node'
21
- );
22
-
23
- const DEFAULT_PREBUILT_ROOT = process.env.UNITY_MCP_PREBUILT_DIR
24
- ? path.resolve(process.env.UNITY_MCP_PREBUILT_DIR)
25
- : path.join(PKG_ROOT, 'prebuilt', 'better-sqlite3');
26
-
27
- export function resolvePlatformKey(
28
- nodeVersion = process.versions.node,
29
- platform = process.platform,
30
- arch = process.arch
31
- ) {
32
- const major = String(nodeVersion).split('.')[0];
33
- return `${platform}-${arch}-node${major}`;
34
- }
35
-
36
- function copyPrebuiltBinding(prebuiltDir, bindingTarget, log) {
37
- const platformKey = resolvePlatformKey();
38
- const source = path.join(prebuiltDir, platformKey, 'better_sqlite3.node');
39
- if (!fs.existsSync(source)) return false;
40
- fs.mkdirSync(path.dirname(bindingTarget), { recursive: true });
41
- fs.copyFileSync(source, bindingTarget);
42
- if (log) log(`[postinstall] Installed better-sqlite3 prebuilt for ${platformKey}`);
43
- return true;
44
- }
45
-
46
- function rebuildNative(bindingTarget, pkgRoot, log) {
47
- log(
48
- '[postinstall] No prebuilt available. Attempting native rebuild (npm rebuild better-sqlite3 --build-from-source)'
49
- );
50
- const result = spawnSync('npm', ['rebuild', 'better-sqlite3', '--build-from-source'], {
51
- stdio: 'inherit',
52
- shell: false,
53
- cwd: pkgRoot
54
- });
55
- if (result.status !== 0) {
56
- throw new Error(`better-sqlite3 rebuild failed with code ${result.status ?? 'null'}`);
57
- }
58
- if (!fs.existsSync(bindingTarget)) {
59
- throw new Error('better-sqlite3 rebuild completed but binding was still not found');
60
- }
61
- return true;
62
- }
63
-
64
- export function ensureBetterSqlite3(options = {}) {
65
- const {
66
- bindingPath = DEFAULT_BINDING_PATH,
67
- prebuiltRoot = DEFAULT_PREBUILT_ROOT,
68
- pkgRoot = PKG_ROOT,
69
- skipNative = process.env.UNITY_MCP_SKIP_NATIVE_BUILD === '1',
70
- forceNative = process.env.UNITY_MCP_FORCE_NATIVE === '1',
71
- skipLegacyFlag = Boolean(process.env.SKIP_SQLITE_REBUILD),
72
- log = console.log,
73
- warn = console.warn
74
- } = options;
75
-
76
- if (skipLegacyFlag && !forceNative) {
77
- warn('[postinstall] SKIP_SQLITE_REBUILD set, skipping better-sqlite3 check');
78
- return { status: 'skipped' };
79
- }
80
-
81
- if (!forceNative && copyPrebuiltBinding(prebuiltRoot, bindingPath, log)) {
82
- return { status: 'copied' };
83
- }
84
-
85
- if (skipNative && !forceNative) {
86
- warn(
87
- '[postinstall] UNITY_MCP_SKIP_NATIVE_BUILD=1 -> skipping native rebuild; sql.js fallback will be used'
88
- );
89
- return { status: 'skipped' };
90
- }
91
-
92
- if (forceNative) {
93
- log('[postinstall] UNITY_MCP_FORCE_NATIVE=1 -> forcing better-sqlite3 rebuild');
94
- } else if (fs.existsSync(bindingPath)) {
95
- // Binding already exists and native rebuild not forced.
96
- return { status: 'existing' };
97
- }
98
-
99
- rebuildNative(bindingPath, pkgRoot, log);
100
- return { status: 'rebuilt' };
101
- }
102
-
103
- function isCliExecution() {
104
- const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
105
- return invokedPath === SCRIPT_PATH;
106
- }
107
-
108
- async function main() {
109
- try {
110
- await ensureBetterSqlite3();
111
- } catch (err) {
112
- console.warn(`[postinstall] Warning: ${err.message}`);
113
- // Do not hard fail install; runtime may still use sql.js fallback in codeIndex
114
- }
115
- }
116
-
117
- if (isCliExecution()) {
118
- main();
119
- }