@akiojin/unity-mcp-server 2.44.0 → 2.45.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.
package/README.md CHANGED
@@ -309,64 +309,50 @@ 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
+ | **fast-sql** | Code index database | npm dependency | Hybrid backend: better-sqlite3 (native) or sql.js (WASM) |
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 fast-sql?
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 **fast-sql** (hybrid SQLite library) 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
+ - **Optimal performance**: Uses better-sqlite3 (native) when available for ~34x faster queries
326
+ - **npx compatible**: Falls back to sql.js (WASM) when native bindings unavailable
327
+ - **Zero native compilation required**: Works immediately via `npx` without build tools
328
+ - **Cross-platform**: Automatic backend selection based on environment
332
329
 
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.
330
+ **Performance comparison** (better-sqlite3 vs sql.js):
331
+ - 50K inserts: 29ms vs 53ms (1.8x faster)
332
+ - 1000 queries: 0.34μs vs 11.78μs (34x faster)
333
+ - LIKE search: 2.5ms vs 7ms (2.8x faster)
334
334
 
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.
335
+ #### csharp-lsp Distribution
336
336
 
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)**:
337
+ **csharp-lsp (downloaded on first use)**:
346
338
 
347
339
  - 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)
340
+ - Too large to bundle in npm package
349
341
  - Downloaded from GitHub Release on first use of script editing tools
350
342
 
351
343
  #### Supported Platforms
352
344
 
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
345
+ | Platform | fast-sql | csharp-lsp |
346
+ |----------|----------|------------|
347
+ | Linux x64 | ✅ Native (better-sqlite3) | ✅ Downloaded (~79 MB) |
348
+ | Linux arm64 | ✅ Native (better-sqlite3) | ✅ Downloaded (~86 MB) |
349
+ | macOS x64 | ✅ Native (better-sqlite3) | ✅ Downloaded (~80 MB) |
350
+ | macOS arm64 (Apple Silicon) | ✅ Native (better-sqlite3) | ✅ Downloaded (~86 MB) |
351
+ | Windows x64 | ✅ Native (better-sqlite3) | ✅ Downloaded (~80 MB) |
352
+ | Windows arm64 | ✅ WASM fallback (sql.js) | ✅ Downloaded (~85 MB) |
366
353
 
367
354
  #### Storage Locations
368
355
 
369
- - **better-sqlite3**: `<package>/prebuilt/better-sqlite3/<platform>/`
370
356
  - **csharp-lsp**: `~/.unity/tools/csharp-lsp/<rid>/`
371
357
  - **Code index database**: `<workspace>/.unity/cache/code-index/code-index.db`
372
358
 
@@ -422,23 +408,6 @@ npm uninstall -g @akiojin/unity-mcp-server
422
408
  npm install -g @akiojin/unity-mcp-server
423
409
  ```
424
410
 
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
411
  ### MCP Client Shows "Capabilities: none"
443
412
 
444
413
  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.45.0",
4
4
  "description": "MCP server and Unity Editor bridge — enables AI assistants to control Unity for AI-assisted workflows",
5
5
  "type": "module",
6
6
  "main": "src/core/server.js",
@@ -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
+ "@akiojin/fast-sql": "file:../packages/fast-sql"
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,11 @@ import path from 'path';
3
3
  import { ProjectInfoProvider } from './projectInfo.js';
4
4
  import { logger } from './config.js';
5
5
 
6
+ // fast-sql helper: execute query and return results
7
+ function querySQL(db, sql) {
8
+ return db.execSql(sql);
9
+ }
10
+
6
11
  // Shared driver availability state across CodeIndex instances
7
12
  const driverStatus = {
8
13
  available: null,
@@ -12,21 +17,21 @@ const driverStatus = {
12
17
 
13
18
  // Shared DB connections (singleton pattern for concurrent access)
14
19
  const sharedConnections = {
15
- writeDb: null,
16
- readDb: null,
20
+ db: null,
17
21
  dbPath: null,
18
- schemaInitialized: false
22
+ schemaInitialized: false,
23
+ SQL: null // sql.js factory
19
24
  };
20
25
 
21
26
  export class CodeIndex {
22
27
  constructor(unityConnection) {
23
28
  this.unityConnection = unityConnection;
24
29
  this.projectInfo = new ProjectInfoProvider(unityConnection);
25
- this.db = null; // legacy reference, points to writeDb
30
+ this.db = null;
26
31
  this.dbPath = null;
27
- this.disabled = false; // set true if better-sqlite3 is unavailable
32
+ this.disabled = false;
28
33
  this.disableReason = null;
29
- this._Database = null;
34
+ this._SQL = null;
30
35
  }
31
36
 
32
37
  async _ensureDriver() {
@@ -35,18 +40,19 @@ export class CodeIndex {
35
40
  this.disableReason = this.disableReason || driverStatus.error;
36
41
  return false;
37
42
  }
38
- if (this._Database) return true;
43
+ if (this._SQL) return true;
39
44
  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;
45
+ // Dynamic import fast-sql (hybrid backend: better-sqlite3 or sql.js fallback)
46
+ const initFastSql = (await import('@akiojin/fast-sql')).default;
47
+ this._SQL = await initFastSql();
48
+ sharedConnections.SQL = this._SQL;
43
49
  driverStatus.available = true;
44
50
  driverStatus.error = null;
45
51
  return true;
46
52
  } catch (e) {
47
- // No fallback - fail fast with clear error
48
53
  this.disabled = true;
49
- this.disableReason = `better-sqlite3 native binding unavailable: ${e?.message || e}. Code index features are disabled.`;
54
+ const errMsg = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
55
+ this.disableReason = `fast-sql unavailable: ${errMsg}. Code index features are disabled.`;
50
56
  driverStatus.available = false;
51
57
  driverStatus.error = this.disableReason;
52
58
  this._logDisable(this.disableReason);
@@ -57,35 +63,33 @@ export class CodeIndex {
57
63
  async open() {
58
64
  if (this.db) return this.db;
59
65
  const ok = await this._ensureDriver();
60
- if (!ok) return null; // index disabled
66
+ if (!ok) return null;
61
67
  const info = await this.projectInfo.get();
62
68
  const dir = info.codeIndexRoot;
63
69
  fs.mkdirSync(dir, { recursive: true });
64
70
  const dbPath = path.join(dir, 'code-index.db');
65
71
  this.dbPath = dbPath;
66
72
 
67
- // Use shared connections for all CodeIndex instances
68
- if (sharedConnections.writeDb && sharedConnections.dbPath === dbPath) {
69
- this.db = sharedConnections.writeDb;
73
+ // Use shared connection for all CodeIndex instances
74
+ if (sharedConnections.db && sharedConnections.dbPath === dbPath) {
75
+ this.db = sharedConnections.db;
70
76
  return this.db;
71
77
  }
72
78
 
73
79
  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 });
80
+ // Load existing database file if exists, otherwise create new
81
+ if (fs.existsSync(dbPath)) {
82
+ const buffer = fs.readFileSync(dbPath);
83
+ this.db = new this._SQL.Database(buffer);
83
84
  } else {
84
- throw new Error('No database driver available');
85
+ this.db = new this._SQL.Database();
85
86
  }
87
+ sharedConnections.db = this.db;
88
+ sharedConnections.dbPath = dbPath;
86
89
  } catch (e) {
87
90
  this.disabled = true;
88
- this.disableReason = e?.message || 'Failed to open code index database';
91
+ const errMsg = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
92
+ this.disableReason = errMsg || 'Failed to open code index database';
89
93
  driverStatus.available = false;
90
94
  driverStatus.error = this.disableReason;
91
95
  this._logDisable(this.disableReason);
@@ -96,36 +100,50 @@ export class CodeIndex {
96
100
  }
97
101
 
98
102
  /**
99
- * Get read-only DB connection for queries (non-blocking during writes)
100
- * Falls back to write connection if read connection unavailable
103
+ * Save in-memory database to file
104
+ * fast-sql requires explicit save when using sql.js backend
101
105
  */
102
- _getReadDb() {
103
- return sharedConnections.readDb || this.db;
106
+ _saveToFile() {
107
+ if (!this.db || !this.dbPath) return;
108
+ try {
109
+ const data = this.db.exportDb();
110
+ const buffer = Buffer.from(data);
111
+ fs.writeFileSync(this.dbPath, buffer);
112
+ } catch (e) {
113
+ const errMsg = e && typeof e === 'object' && 'message' in e ? e.message : String(e);
114
+ logger.warn(`[index] Failed to save database: ${errMsg}`);
115
+ }
104
116
  }
105
117
 
106
118
  _logDisable(reason) {
107
119
  if (driverStatus.logged) return;
108
120
  driverStatus.logged = true;
109
121
  try {
110
- logger?.warn?.(`[index] code index disabled: ${reason}`);
111
- } catch {}
122
+ if (logger && typeof logger.warn === 'function') {
123
+ logger.warn(`[index] code index disabled: ${reason}`);
124
+ }
125
+ } catch {
126
+ // Ignore logging errors
127
+ }
112
128
  }
113
129
 
114
130
  _initSchema() {
115
131
  if (!this.db) return;
116
- const db = this.db;
117
- db.exec(`
118
- PRAGMA journal_mode=WAL;
119
- PRAGMA busy_timeout=5000;
132
+ // fast-sql applies optimal PRAGMAs automatically
133
+ this.db.run(`
120
134
  CREATE TABLE IF NOT EXISTS meta (
121
135
  key TEXT PRIMARY KEY,
122
136
  value TEXT
123
- );
137
+ )
138
+ `);
139
+ this.db.run(`
124
140
  CREATE TABLE IF NOT EXISTS files (
125
141
  path TEXT PRIMARY KEY,
126
142
  sig TEXT,
127
143
  updatedAt TEXT
128
- );
144
+ )
145
+ `);
146
+ this.db.run(`
129
147
  CREATE TABLE IF NOT EXISTS symbols (
130
148
  path TEXT NOT NULL,
131
149
  name TEXT NOT NULL,
@@ -134,33 +152,36 @@ export class CodeIndex {
134
152
  namespace TEXT,
135
153
  line INTEGER,
136
154
  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);
155
+ )
141
156
  `);
157
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)');
158
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)');
159
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path)');
160
+ this._saveToFile();
142
161
  }
143
162
 
144
163
  async isReady() {
145
164
  const db = await this.open();
146
165
  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;
166
+ const result = querySQL(db, 'SELECT COUNT(*) AS c FROM symbols');
167
+ const count = result.length > 0 && result[0].values.length > 0 ? result[0].values[0][0] : 0;
168
+ return count > 0;
151
169
  }
152
170
 
153
171
  async clearAndLoad(symbols) {
154
172
  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(
173
+ if (!db) throw new Error('CodeIndex is unavailable (fast-sql not loaded)');
174
+
175
+ db.run('BEGIN TRANSACTION');
176
+ try {
177
+ db.run('DELETE FROM symbols');
178
+ db.run('DELETE FROM files');
179
+
180
+ const stmt = db.prepare(
181
+ 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
182
+ );
183
+ for (const r of symbols || []) {
184
+ stmt.run([
164
185
  r.path,
165
186
  r.name,
166
187
  r.kind,
@@ -168,14 +189,20 @@ export class CodeIndex {
168
189
  r.ns || r.namespace || null,
169
190
  r.line || null,
170
191
  r.column || null
171
- );
192
+ ]);
172
193
  }
173
- db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)').run(
174
- 'lastIndexedAt',
175
- new Date().toISOString()
176
- );
177
- });
178
- tx(symbols || []);
194
+ stmt.free();
195
+
196
+ const metaStmt = db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)');
197
+ metaStmt.run(['lastIndexedAt', new Date().toISOString()]);
198
+ metaStmt.free();
199
+
200
+ db.run('COMMIT');
201
+ this._saveToFile();
202
+ } catch (e) {
203
+ db.run('ROLLBACK');
204
+ throw e;
205
+ }
179
206
  return { total: symbols?.length || 0 };
180
207
  }
181
208
 
@@ -183,90 +210,127 @@ export class CodeIndex {
183
210
  async getFiles() {
184
211
  const db = await this.open();
185
212
  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();
213
+ const result = querySQL(db, 'SELECT path, sig FROM files');
189
214
  const map = new Map();
190
- for (const r of rows) map.set(String(r.path), String(r.sig || ''));
215
+ if (result.length > 0) {
216
+ for (const row of result[0].values) {
217
+ map.set(String(row[0]), String(row[1] || ''));
218
+ }
219
+ }
191
220
  return map;
192
221
  }
193
222
 
194
223
  async upsertFile(pathStr, sig) {
195
224
  const db = await this.open();
196
225
  if (!db) return;
197
- db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)').run(
198
- pathStr,
199
- sig || '',
200
- new Date().toISOString()
201
- );
226
+ const stmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
227
+ stmt.run([pathStr, sig || '', new Date().toISOString()]);
228
+ stmt.free();
229
+ this._saveToFile();
202
230
  }
203
231
 
204
232
  async removeFile(pathStr) {
205
233
  const db = await this.open();
206
234
  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);
235
+ db.run('BEGIN TRANSACTION');
236
+ try {
237
+ const stmt1 = db.prepare('DELETE FROM symbols WHERE path = ?');
238
+ stmt1.run([pathStr]);
239
+ stmt1.free();
240
+
241
+ const stmt2 = db.prepare('DELETE FROM files WHERE path = ?');
242
+ stmt2.run([pathStr]);
243
+ stmt2.free();
244
+
245
+ db.run('COMMIT');
246
+ this._saveToFile();
247
+ } catch (e) {
248
+ db.run('ROLLBACK');
249
+ throw e;
250
+ }
212
251
  }
213
252
 
214
253
  async replaceSymbolsForPath(pathStr, rows) {
215
254
  const db = await this.open();
216
255
  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(
256
+
257
+ db.run('BEGIN TRANSACTION');
258
+ try {
259
+ const delStmt = db.prepare('DELETE FROM symbols WHERE path = ?');
260
+ delStmt.run([pathStr]);
261
+ delStmt.free();
262
+
263
+ const insertStmt = db.prepare(
220
264
  'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
221
265
  );
222
- for (const r of list)
223
- insert.run(
224
- p,
266
+ for (const r of rows || []) {
267
+ insertStmt.run([
268
+ pathStr,
225
269
  r.name,
226
270
  r.kind,
227
271
  r.container || null,
228
272
  r.ns || r.namespace || null,
229
273
  r.line || null,
230
274
  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 || []);
275
+ ]);
276
+ }
277
+ insertStmt.free();
278
+
279
+ const metaStmt = db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)');
280
+ metaStmt.run(['lastIndexedAt', new Date().toISOString()]);
281
+ metaStmt.free();
282
+
283
+ db.run('COMMIT');
284
+ this._saveToFile();
285
+ } catch (e) {
286
+ db.run('ROLLBACK');
287
+ throw e;
288
+ }
238
289
  }
239
290
 
240
291
  async querySymbols({ name, kind, scope = 'all', exact = false }) {
241
292
  const db = await this.open();
242
293
  if (!db) return [];
243
- // Use read-only connection for non-blocking query
244
- const readDb = this._getReadDb();
294
+
245
295
  let sql = 'SELECT path,name,kind,container,namespace,line,column FROM symbols WHERE 1=1';
246
- const params = {};
296
+ const params = [];
297
+
247
298
  if (name) {
248
299
  if (exact) {
249
- sql += ' AND name = @name';
250
- params.name = name;
300
+ sql += ' AND name = ?';
301
+ params.push(name);
251
302
  } else {
252
- sql += ' AND name LIKE @name';
253
- params.name = `%${name}%`;
303
+ sql += ' AND name LIKE ?';
304
+ params.push(`%${name}%`);
254
305
  }
255
306
  }
256
307
  if (kind) {
257
- sql += ' AND kind = @kind';
258
- params.kind = kind;
308
+ sql += ' AND kind = ?';
309
+ params.push(kind);
310
+ }
311
+
312
+ const stmt = db.prepare(sql);
313
+ if (params.length > 0) {
314
+ stmt.bind(params);
315
+ }
316
+
317
+ const rows = [];
318
+ while (stmt.step()) {
319
+ const row = stmt.getAsObject();
320
+ rows.push(row);
259
321
  }
260
- const rows = readDb.prepare(sql).all(params);
261
- // Apply path-based scope filter in JS (simpler than CASE in SQL)
322
+ stmt.free();
323
+
324
+ // Apply path-based scope filter in JS
262
325
  const filtered = rows.filter(r => {
263
- const p = String(r.path || '').replace(/\\\\/g, '/');
326
+ const p = String(r.path || '').replace(/\\/g, '/');
264
327
  if (scope === 'assets') return p.startsWith('Assets/');
265
328
  if (scope === 'packages')
266
329
  return p.startsWith('Packages/') || p.includes('Library/PackageCache/');
267
330
  if (scope === 'embedded') return p.startsWith('Packages/');
268
331
  return true;
269
332
  });
333
+
270
334
  return filtered.map(r => ({
271
335
  path: r.path,
272
336
  name: r.name,
@@ -281,13 +345,32 @@ export class CodeIndex {
281
345
  async getStats() {
282
346
  const db = await this.open();
283
347
  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;
348
+
349
+ const countResult = querySQL(db, 'SELECT COUNT(*) AS c FROM symbols');
350
+ const total =
351
+ countResult.length > 0 && countResult[0].values.length > 0 ? countResult[0].values[0][0] : 0;
352
+
353
+ const metaResult = querySQL(db, "SELECT value FROM meta WHERE key = 'lastIndexedAt'");
287
354
  const last =
288
- readDb.prepare("SELECT value AS v FROM meta WHERE key = 'lastIndexedAt'").get()?.v || null;
355
+ metaResult.length > 0 && metaResult[0].values.length > 0 ? metaResult[0].values[0][0] : null;
356
+
289
357
  return { total, lastIndexedAt: last };
290
358
  }
359
+
360
+ /**
361
+ * Close the database connection
362
+ */
363
+ close() {
364
+ if (this.db) {
365
+ this._saveToFile();
366
+ this.db.close();
367
+ this.db = null;
368
+ }
369
+ if (sharedConnections.db === this.db) {
370
+ sharedConnections.db = null;
371
+ sharedConnections.dbPath = null;
372
+ }
373
+ }
291
374
  }
292
375
 
293
376
  // Test-only helper to reset cached driver status between runs
@@ -296,18 +379,15 @@ export function __resetCodeIndexDriverStatusForTest() {
296
379
  driverStatus.error = null;
297
380
  driverStatus.logged = false;
298
381
  // Also reset shared connections
299
- if (sharedConnections.writeDb) {
382
+ if (sharedConnections.db) {
300
383
  try {
301
- sharedConnections.writeDb.close();
302
- } catch {}
303
- }
304
- if (sharedConnections.readDb) {
305
- try {
306
- sharedConnections.readDb.close();
307
- } catch {}
384
+ sharedConnections.db.close();
385
+ } catch {
386
+ // Ignore close errors
387
+ }
308
388
  }
309
- sharedConnections.writeDb = null;
310
- sharedConnections.readDb = null;
389
+ sharedConnections.db = null;
311
390
  sharedConnections.dbPath = null;
312
391
  sharedConnections.schemaInitialized = false;
392
+ sharedConnections.SQL = null;
313
393
  }
@@ -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
+ * fast-sql database operations (hybrid backend with better-sqlite3 or sql.js fallback).
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 fast-sql - hybrid backend with better-sqlite3 or sql.js fallback).
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,27 @@ import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { fileURLToPath } from 'url';
18
18
 
19
+ // fast-sql helper: run SQL statement
20
+ function runSQL(db, sql) {
21
+ return db.run(sql);
22
+ }
23
+
24
+ // fast-sql helper: execute query and return results
25
+ function querySQL(db, sql) {
26
+ return db.execSql(sql);
27
+ }
28
+
29
+ // Save fast-sql database to file
30
+ function saveDatabase(db, dbPath) {
31
+ try {
32
+ const data = db.exportDb();
33
+ const buffer = Buffer.from(data);
34
+ fs.writeFileSync(dbPath, buffer);
35
+ } catch (e) {
36
+ log('warn', `[worker] Failed to save database: ${e.message}`);
37
+ }
38
+ }
39
+
19
40
  const __filename = fileURLToPath(import.meta.url);
20
41
  const __dirname = path.dirname(__filename);
21
42
 
@@ -160,31 +181,49 @@ async function runBuild() {
160
181
 
161
182
  log('info', `[worker] Starting build: projectRoot=${projectRoot}, dbPath=${dbPath}`);
162
183
 
184
+ let db = null;
185
+
163
186
  try {
164
- // Dynamic import better-sqlite3 in worker thread
165
- let Database;
187
+ // Dynamic import fast-sql in worker thread (hybrid: better-sqlite3 or sql.js fallback)
188
+ let SQL;
166
189
  try {
167
- const mod = await import('better-sqlite3');
168
- Database = mod.default || mod;
190
+ const initFastSql = (await import('@akiojin/fast-sql')).default;
191
+ SQL = await initFastSql();
169
192
  } catch (e) {
170
- throw new Error(`better-sqlite3 unavailable in worker: ${e.message}`);
193
+ throw new Error(`fast-sql unavailable in worker: ${e.message}`);
171
194
  }
172
195
 
173
- // Open database
174
- const db = new Database(dbPath);
175
- db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;');
196
+ // Open or create database
197
+ if (fs.existsSync(dbPath)) {
198
+ const buffer = fs.readFileSync(dbPath);
199
+ db = new SQL.Database(buffer);
200
+ } else {
201
+ db = new SQL.Database();
202
+ }
176
203
 
177
- // Initialize schema if needed
178
- db.exec(`
204
+ // Initialize schema if needed (fast-sql applies optimal PRAGMAs automatically)
205
+ runSQL(
206
+ db,
207
+ `
179
208
  CREATE TABLE IF NOT EXISTS meta (
180
209
  key TEXT PRIMARY KEY,
181
210
  value TEXT
182
- );
211
+ )
212
+ `
213
+ );
214
+ runSQL(
215
+ db,
216
+ `
183
217
  CREATE TABLE IF NOT EXISTS files (
184
218
  path TEXT PRIMARY KEY,
185
219
  sig TEXT,
186
220
  updatedAt TEXT
187
- );
221
+ )
222
+ `
223
+ );
224
+ runSQL(
225
+ db,
226
+ `
188
227
  CREATE TABLE IF NOT EXISTS symbols (
189
228
  path TEXT NOT NULL,
190
229
  name TEXT NOT NULL,
@@ -193,11 +232,12 @@ async function runBuild() {
193
232
  namespace TEXT,
194
233
  line INTEGER,
195
234
  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
- `);
235
+ )
236
+ `
237
+ );
238
+ runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)');
239
+ runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)');
240
+ runSQL(db, 'CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path)');
201
241
 
202
242
  // Scan for C# files
203
243
  const roots = [
@@ -212,8 +252,13 @@ async function runBuild() {
212
252
  log('info', `[worker] Found ${files.length} C# files to process`);
213
253
 
214
254
  // 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]));
255
+ const currentResult = querySQL(db, 'SELECT path, sig FROM files');
256
+ const current = new Map();
257
+ if (currentResult.length > 0) {
258
+ for (const row of currentResult[0].values) {
259
+ current.set(row[0], row[1]);
260
+ }
261
+ }
217
262
 
218
263
  // Determine changes
219
264
  const wanted = new Map(files.map(abs => [toRel(abs, projectRoot), makeSig(abs)]));
@@ -230,11 +275,14 @@ async function runBuild() {
230
275
  log('info', `[worker] Changes: ${changed.length} to update, ${removed.length} to remove`);
231
276
 
232
277
  // Remove vanished files
233
- const deleteSymbols = db.prepare('DELETE FROM symbols WHERE path = ?');
234
- const deleteFile = db.prepare('DELETE FROM files WHERE path = ?');
235
278
  for (const rel of removed) {
236
- deleteSymbols.run(rel);
237
- deleteFile.run(rel);
279
+ const stmt1 = db.prepare('DELETE FROM symbols WHERE path = ?');
280
+ stmt1.run([rel]);
281
+ stmt1.free();
282
+
283
+ const stmt2 = db.prepare('DELETE FROM files WHERE path = ?');
284
+ stmt2.run([rel]);
285
+ stmt2.free();
238
286
  }
239
287
 
240
288
  // Prepare for updates
@@ -271,14 +319,6 @@ async function runBuild() {
271
319
  throw lastErr || new Error('documentSymbol failed');
272
320
  };
273
321
 
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
322
  // Process files sequentially (concurrency=1 for non-blocking)
283
323
  for (let i = 0; i < absList.length; i++) {
284
324
  const abs = absList[i];
@@ -290,14 +330,33 @@ async function runBuild() {
290
330
  const rows = toRows(uri, docSymbols, projectRoot);
291
331
 
292
332
  // Update database in transaction
293
- db.transaction(() => {
294
- deleteSymbolsForPath.run(rel);
333
+ runSQL(db, 'BEGIN TRANSACTION');
334
+ try {
335
+ const delStmt = db.prepare('DELETE FROM symbols WHERE path = ?');
336
+ delStmt.run([rel]);
337
+ delStmt.free();
338
+
339
+ const insertStmt = db.prepare(
340
+ 'INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)'
341
+ );
295
342
  for (const r of rows) {
296
- insertSymbol.run(r.path, r.name, r.kind, r.container, r.ns, r.line, r.column);
343
+ insertStmt.run([r.path, r.name, r.kind, r.container, r.ns, r.line, r.column]);
297
344
  }
298
- upsertFile.run(rel, wanted.get(rel), new Date().toISOString());
299
- updateMeta.run(new Date().toISOString());
300
- })();
345
+ insertStmt.free();
346
+
347
+ const fileStmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
348
+ fileStmt.run([rel, wanted.get(rel), new Date().toISOString()]);
349
+ fileStmt.free();
350
+
351
+ const metaStmt = db.prepare("REPLACE INTO meta(key,value) VALUES ('lastIndexedAt',?)");
352
+ metaStmt.run([new Date().toISOString()]);
353
+ metaStmt.free();
354
+
355
+ runSQL(db, 'COMMIT');
356
+ } catch (txErr) {
357
+ runSQL(db, 'ROLLBACK');
358
+ throw txErr;
359
+ }
301
360
 
302
361
  updated++;
303
362
  } catch (err) {
@@ -332,12 +391,20 @@ async function runBuild() {
332
391
  }
333
392
  }
334
393
 
394
+ // Save database to file
395
+ saveDatabase(db, dbPath);
396
+
335
397
  // Get final stats
336
- const total = db.prepare('SELECT COUNT(*) AS c FROM symbols').get().c || 0;
398
+ const countResult = querySQL(db, 'SELECT COUNT(*) AS c FROM symbols');
399
+ const total =
400
+ countResult.length > 0 && countResult[0].values.length > 0 ? countResult[0].values[0][0] : 0;
401
+
402
+ const metaResult = querySQL(db, "SELECT value FROM meta WHERE key = 'lastIndexedAt'");
337
403
  const lastIndexedAt =
338
- db.prepare("SELECT value AS v FROM meta WHERE key = 'lastIndexedAt'").get()?.v || null;
404
+ metaResult.length > 0 && metaResult[0].values.length > 0 ? metaResult[0].values[0][0] : null;
339
405
 
340
406
  db.close();
407
+ db = null;
341
408
 
342
409
  const result = {
343
410
  updatedFiles: updated,
@@ -354,6 +421,13 @@ async function runBuild() {
354
421
  sendComplete(result);
355
422
  } catch (error) {
356
423
  log('error', `[worker] Build failed: ${error.message}`);
424
+ if (db) {
425
+ try {
426
+ db.close();
427
+ } catch {
428
+ // Ignore close errors
429
+ }
430
+ }
357
431
  sendError(error);
358
432
  }
359
433
  }
@@ -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 fast-sql 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 fast-sql is properly installed. After reinstalling dependencies, 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 || 'fast-sql 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 fast-sql is properly installed. After reinstalling dependencies, 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 fast-sql is properly installed. After reinstalling dependencies, 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
- }