@akiojin/unity-mcp-server 2.45.5 → 3.0.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.
@@ -1,8 +1,21 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { LRUCache } from 'lru-cache';
3
4
  import { ProjectInfoProvider } from './projectInfo.js';
4
5
  import { logger } from './config.js';
5
6
 
7
+ // Phase 4: Query result cache (80% reduction for repeated queries)
8
+ const queryCache = new LRUCache({
9
+ max: 500, // Max 500 cached queries
10
+ ttl: 1000 * 60 * 5 // 5 minute TTL
11
+ });
12
+
13
+ // Stats cache with shorter TTL
14
+ const statsCache = new LRUCache({
15
+ max: 1,
16
+ ttl: 1000 * 60 // 1 minute TTL for stats
17
+ });
18
+
6
19
  // fast-sql helper: execute query and return results
7
20
  function querySQL(db, sql) {
8
21
  return db.execSql(sql);
@@ -138,7 +151,10 @@ export class CodeIndex {
138
151
 
139
152
  _initSchema() {
140
153
  if (!this.db) return;
141
- // fast-sql applies optimal PRAGMAs automatically
154
+ // Phase 5: Explicit PRAGMA optimization
155
+ this.db.run('PRAGMA cache_size = 16000'); // 64MB cache (16000 * 4KB pages)
156
+ this.db.run('PRAGMA temp_store = MEMORY'); // Faster temp operations
157
+ this.db.run('PRAGMA synchronous = NORMAL'); // Balanced safety/speed
142
158
  this.db.run(`
143
159
  CREATE TABLE IF NOT EXISTS meta (
144
160
  key TEXT PRIMARY KEY,
@@ -166,6 +182,9 @@ export class CodeIndex {
166
182
  this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)');
167
183
  this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)');
168
184
  this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path)');
185
+ // Composite indexes for faster multi-condition queries
186
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_name_kind ON symbols(name, kind)');
187
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_symbols_path_name ON symbols(path, name)');
169
188
  this._saveToFile();
170
189
  }
171
190
 
@@ -181,6 +200,10 @@ export class CodeIndex {
181
200
  const db = await this.open();
182
201
  if (!db) throw new Error('CodeIndex is unavailable (fast-sql not loaded)');
183
202
 
203
+ // Phase 4: Invalidate caches on write
204
+ queryCache.clear();
205
+ statsCache.clear();
206
+
184
207
  db.run('BEGIN TRANSACTION');
185
208
  try {
186
209
  db.run('DELETE FROM symbols');
@@ -232,6 +255,8 @@ export class CodeIndex {
232
255
  async upsertFile(pathStr, sig) {
233
256
  const db = await this.open();
234
257
  if (!db) return;
258
+ // Phase 4: Invalidate caches on write
259
+ statsCache.clear();
235
260
  const stmt = db.prepare('REPLACE INTO files(path,sig,updatedAt) VALUES (?,?,?)');
236
261
  stmt.run([pathStr, sig || '', new Date().toISOString()]);
237
262
  stmt.free();
@@ -241,6 +266,9 @@ export class CodeIndex {
241
266
  async removeFile(pathStr) {
242
267
  const db = await this.open();
243
268
  if (!db) return;
269
+ // Phase 4: Invalidate caches on write
270
+ queryCache.clear();
271
+ statsCache.clear();
244
272
  db.run('BEGIN TRANSACTION');
245
273
  try {
246
274
  const stmt1 = db.prepare('DELETE FROM symbols WHERE path = ?');
@@ -263,6 +291,10 @@ export class CodeIndex {
263
291
  const db = await this.open();
264
292
  if (!db) return;
265
293
 
294
+ // Phase 4: Invalidate caches on write
295
+ queryCache.clear();
296
+ statsCache.clear();
297
+
266
298
  db.run('BEGIN TRANSACTION');
267
299
  try {
268
300
  const delStmt = db.prepare('DELETE FROM symbols WHERE path = ?');
@@ -298,6 +330,11 @@ export class CodeIndex {
298
330
  }
299
331
 
300
332
  async querySymbols({ name, kind, scope = 'all', exact = false }) {
333
+ // Phase 4: Check cache first
334
+ const cacheKey = JSON.stringify({ name, kind, scope, exact });
335
+ const cached = queryCache.get(cacheKey);
336
+ if (cached) return cached;
337
+
301
338
  const db = await this.open();
302
339
  if (!db) return [];
303
340
 
@@ -318,6 +355,16 @@ export class CodeIndex {
318
355
  params.push(kind);
319
356
  }
320
357
 
358
+ // Apply scope filter directly in SQL for better performance
359
+ if (scope === 'assets') {
360
+ sql += " AND path LIKE 'Assets/%'";
361
+ } else if (scope === 'packages') {
362
+ sql += " AND (path LIKE 'Packages/%' OR path LIKE '%Library/PackageCache/%')";
363
+ } else if (scope === 'embedded') {
364
+ sql += " AND path LIKE 'Packages/%'";
365
+ }
366
+ // scope === 'all' requires no additional filter
367
+
321
368
  const stmt = db.prepare(sql);
322
369
  if (params.length > 0) {
323
370
  stmt.bind(params);
@@ -330,17 +377,7 @@ export class CodeIndex {
330
377
  }
331
378
  stmt.free();
332
379
 
333
- // Apply path-based scope filter in JS
334
- const filtered = rows.filter(r => {
335
- const p = String(r.path || '').replace(/\\/g, '/');
336
- if (scope === 'assets') return p.startsWith('Assets/');
337
- if (scope === 'packages')
338
- return p.startsWith('Packages/') || p.includes('Library/PackageCache/');
339
- if (scope === 'embedded') return p.startsWith('Packages/');
340
- return true;
341
- });
342
-
343
- return filtered.map(r => ({
380
+ const result = rows.map(r => ({
344
381
  path: r.path,
345
382
  name: r.name,
346
383
  kind: r.kind,
@@ -349,9 +386,17 @@ export class CodeIndex {
349
386
  line: r.line,
350
387
  column: r.column
351
388
  }));
389
+
390
+ // Cache the result
391
+ queryCache.set(cacheKey, result);
392
+ return result;
352
393
  }
353
394
 
354
395
  async getStats() {
396
+ // Phase 4: Check stats cache first
397
+ const cached = statsCache.get('stats');
398
+ if (cached) return cached;
399
+
355
400
  const db = await this.open();
356
401
  if (!db) return { total: 0, lastIndexedAt: null };
357
402
 
@@ -363,7 +408,9 @@ export class CodeIndex {
363
408
  const last =
364
409
  metaResult.length > 0 && metaResult[0].values.length > 0 ? metaResult[0].values[0][0] : null;
365
410
 
366
- return { total, lastIndexedAt: last };
411
+ const result = { total, lastIndexedAt: last };
412
+ statsCache.set('stats', result);
413
+ return result;
367
414
  }
368
415
 
369
416
  /**
@@ -387,6 +434,9 @@ export function __resetCodeIndexDriverStatusForTest() {
387
434
  driverStatus.available = null;
388
435
  driverStatus.error = null;
389
436
  driverStatus.logged = false;
437
+ // Phase 4: Clear query caches
438
+ queryCache.clear();
439
+ statsCache.clear();
390
440
  // Also reset shared connections
391
441
  if (sharedConnections.db) {
392
442
  try {
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import os from 'os';
2
3
  import path from 'path';
3
4
  import * as findUpPkg from 'find-up';
4
5
  import { MCPLogger } from './mcpLogger.js';
@@ -67,20 +68,13 @@ function resolvePackageVersion() {
67
68
  /**
68
69
  * Base configuration for Unity MCP Server Server
69
70
  */
70
- const envUnityHost = process.env.UNITY_BIND_HOST || process.env.UNITY_HOST || null;
71
-
72
- const envMcpHost =
73
- process.env.UNITY_MCP_HOST || process.env.UNITY_CLIENT_HOST || process.env.UNITY_HOST || null;
74
-
75
- const envBindHost = process.env.UNITY_BIND_HOST || null;
76
-
77
71
  const baseConfig = {
78
72
  // Unity connection settings
79
73
  unity: {
80
- unityHost: envUnityHost,
81
- mcpHost: envMcpHost,
82
- bindHost: envBindHost,
83
- port: parseInt(process.env.UNITY_PORT || '', 10) || 6400,
74
+ unityHost: null,
75
+ mcpHost: null,
76
+ bindHost: null,
77
+ port: 6400,
84
78
  reconnectDelay: 1000,
85
79
  maxReconnectDelay: 30000,
86
80
  reconnectBackoffMultiplier: 2,
@@ -96,24 +90,21 @@ const baseConfig = {
96
90
 
97
91
  // Logging settings
98
92
  logging: {
99
- level: process.env.LOG_LEVEL || 'info',
93
+ level: 'info',
100
94
  prefix: '[unity-mcp-server]'
101
95
  },
102
96
 
103
97
  // HTTP transport (off by default)
104
98
  http: {
105
- enabled: (process.env.UNITY_MCP_HTTP_ENABLED || 'false').toLowerCase() === 'true',
106
- host: process.env.UNITY_MCP_HTTP_HOST || '0.0.0.0',
107
- port: parseInt(process.env.UNITY_MCP_HTTP_PORT || '', 10) || 6401,
99
+ enabled: false,
100
+ host: '0.0.0.0',
101
+ port: 6401,
108
102
  healthPath: '/healthz',
109
- allowedHosts: (process.env.UNITY_MCP_HTTP_ALLOWED_HOSTS || 'localhost,127.0.0.1')
110
- .split(',')
111
- .map(h => h.trim())
112
- .filter(h => h.length > 0)
103
+ allowedHosts: ['localhost', '127.0.0.1']
113
104
  },
114
105
 
115
106
  telemetry: {
116
- enabled: (process.env.UNITY_MCP_TELEMETRY || 'off').toLowerCase() === 'on',
107
+ enabled: false,
117
108
  destinations: [],
118
109
  fields: []
119
110
  },
@@ -123,13 +114,13 @@ const baseConfig = {
123
114
  // Search-related defaults and engine selection
124
115
  search: {
125
116
  // detail alias: 'compact' maps to returnMode 'snippets'
126
- defaultDetail: (process.env.SEARCH_DEFAULT_DETAIL || 'compact').toLowerCase(), // compact|metadata|snippets|full
127
- engine: (process.env.SEARCH_ENGINE || 'naive').toLowerCase() // naive|treesitter (future)
117
+ defaultDetail: 'compact', // compact|metadata|snippets|full
118
+ engine: 'naive' // naive|treesitter (future)
128
119
  },
129
120
 
130
121
  // LSP client defaults
131
122
  lsp: {
132
- requestTimeoutMs: Number(process.env.LSP_REQUEST_TIMEOUT_MS || 60000)
123
+ requestTimeoutMs: 60000
133
124
  },
134
125
 
135
126
  // Indexing (code index) settings
@@ -137,95 +128,48 @@ const baseConfig = {
137
128
  // Enable periodic incremental index updates (polling watcher)
138
129
  watch: true,
139
130
  // Polling interval (ms)
140
- intervalMs: Number(process.env.INDEX_WATCH_INTERVAL_MS || 15000),
131
+ intervalMs: 15000,
141
132
  // Build options
142
- concurrency: Number(process.env.INDEX_CONCURRENCY || 8),
143
- retry: Number(process.env.INDEX_RETRY || 2),
144
- reportEvery: Number(process.env.INDEX_REPORT_EVERY || 500)
133
+ concurrency: 8,
134
+ retry: 2,
135
+ reportEvery: 500
145
136
  }
146
137
  };
147
138
 
148
139
  /**
149
- * External config resolution (no legacy compatibility):
150
- * Priority:
151
- * 1) UNITY_MCP_CONFIG (explicit file path)
152
- * 2) ./.unity/config.json (project-local)
153
- * 3) ~/.unity/config.json (user-global)
154
- * If none found, create ./.unity/config.json with defaults.
140
+ * External config resolution:
141
+ * - Uses the nearest `.unity/config.json` found by walking up from the current working directory.
142
+ * - Intentionally ignores `~/.unity/config.json` (user-global).
155
143
  */
156
- function ensureDefaultProjectConfig(baseDir) {
157
- const dir = path.resolve(baseDir, '.unity');
158
- const file = path.join(dir, 'config.json');
159
-
160
- try {
161
- if (!fs.existsSync(dir)) {
162
- fs.mkdirSync(dir, { recursive: true });
163
- }
164
-
165
- if (!fs.existsSync(file)) {
166
- const inferredRoot = fs.existsSync(path.join(baseDir, 'Assets')) ? baseDir : '';
167
- const defaultConfig = {
168
- unity: {
169
- unityHost: 'localhost',
170
- mcpHost: 'localhost',
171
- port: 6400
172
- },
173
- project: {
174
- root: inferredRoot ? inferredRoot.replace(/\\/g, '/') : ''
175
- }
176
- };
177
- fs.writeFileSync(file, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8');
178
- }
179
- return file;
180
- } catch (error) {
181
- return null;
182
- }
183
- }
184
-
185
144
  function loadExternalConfig() {
186
145
  if (typeof findUpSyncCompat !== 'function') {
187
146
  return {};
188
147
  }
189
- const explicitPath = process.env.UNITY_MCP_CONFIG;
148
+ let userGlobalPath = null;
149
+ try {
150
+ const homeDir = os.homedir();
151
+ userGlobalPath = homeDir ? path.resolve(homeDir, '.unity', 'config.json') : null;
152
+ } catch {}
190
153
 
191
154
  const projectPath = findUpSyncCompat(
192
155
  directory => {
193
156
  const candidate = path.resolve(directory, '.unity', 'config.json');
157
+ if (userGlobalPath && path.resolve(candidate) === userGlobalPath) return undefined;
194
158
  return fs.existsSync(candidate) ? candidate : undefined;
195
159
  },
196
160
  { cwd: process.cwd() }
197
161
  );
198
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
199
- const userPath = homeDir ? path.resolve(homeDir, '.unity', 'config.json') : null;
200
162
 
201
- const candidates = [explicitPath, projectPath, userPath].filter(Boolean);
202
- for (const p of candidates) {
203
- try {
204
- if (p && fs.existsSync(p)) {
205
- const raw = fs.readFileSync(p, 'utf8');
206
- const json = JSON.parse(raw);
207
- const out = json && typeof json === 'object' ? json : {};
208
- out.__configPath = p;
209
- return out;
210
- }
211
- } catch (e) {
212
- return { __configLoadError: `${p}: ${e.message}` };
213
- }
214
- }
215
- const fallbackPath = ensureDefaultProjectConfig(process.cwd());
216
- if (fallbackPath && fs.existsSync(fallbackPath)) {
217
- try {
218
- const raw = fs.readFileSync(fallbackPath, 'utf8');
219
- const json = JSON.parse(raw);
220
- const out = json && typeof json === 'object' ? json : {};
221
- out.__configPath = fallbackPath;
222
- out.__configGenerated = true;
223
- return out;
224
- } catch (e) {
225
- return { __configLoadError: `${fallbackPath}: ${e.message}` };
226
- }
163
+ if (!projectPath) return {};
164
+ try {
165
+ const raw = fs.readFileSync(projectPath, 'utf8');
166
+ const json = JSON.parse(raw);
167
+ const out = json && typeof json === 'object' ? json : {};
168
+ out.__configPath = projectPath;
169
+ return out;
170
+ } catch (e) {
171
+ return { __configLoadError: `${projectPath}: ${e.message}` };
227
172
  }
228
- return {};
229
173
  }
230
174
 
231
175
  const external = loadExternalConfig();
@@ -234,22 +178,22 @@ export const config = merge(baseConfig, external);
234
178
  const normalizeUnityConfig = () => {
235
179
  const unityConfig = config.unity || (config.unity = {});
236
180
 
237
- // Legacy aliases coming from config files or env vars
181
+ // Legacy aliases coming from config files
238
182
  const legacyHost = unityConfig.host;
239
183
  const legacyClientHost = unityConfig.clientHost;
240
184
  const legacyBindHost = unityConfig.bindHost;
241
185
 
242
186
  if (!unityConfig.unityHost) {
243
- unityConfig.unityHost = legacyBindHost || legacyHost || envUnityHost || 'localhost';
187
+ unityConfig.unityHost = legacyBindHost || legacyHost || 'localhost';
244
188
  }
245
189
 
246
190
  if (!unityConfig.mcpHost) {
247
- unityConfig.mcpHost = legacyClientHost || envMcpHost || legacyHost || unityConfig.unityHost;
191
+ unityConfig.mcpHost = legacyClientHost || legacyHost || unityConfig.unityHost;
248
192
  }
249
193
 
250
194
  // Keep bindHost for backwards compatibility with legacy code paths
251
195
  if (!unityConfig.bindHost) {
252
- unityConfig.bindHost = legacyBindHost || envBindHost || unityConfig.unityHost;
196
+ unityConfig.bindHost = legacyBindHost || unityConfig.unityHost;
253
197
  }
254
198
 
255
199
  // Maintain legacy properties so older handlers keep working
@@ -291,7 +235,7 @@ if (config.__configLoadError) {
291
235
  // Startup debug log: output config info to stderr for troubleshooting
292
236
  // This helps diagnose connection issues (especially in WSL2/Docker environments)
293
237
  console.error(`[unity-mcp-server] Startup config:`);
294
- console.error(`[unity-mcp-server] Config file: ${config.__configPath || '(defaults)'}`);
238
+ console.error(`[unity-mcp-server] Config file: ${config.__configPath || '(not found)'}`);
295
239
  console.error(
296
240
  `[unity-mcp-server] Unity host: ${config.unity.mcpHost || config.unity.unityHost || 'localhost'}`
297
241
  );
@@ -1,8 +1,36 @@
1
+ import fs from 'fs';
1
2
  import path from 'path';
2
3
  import { logger, config, WORKSPACE_ROOT } from './config.js';
3
4
 
4
5
  const normalize = p => p.replace(/\\/g, '/');
5
6
 
7
+ const looksLikeUnityProjectRoot = dir => {
8
+ try {
9
+ return (
10
+ fs.existsSync(path.join(dir, 'Assets')) &&
11
+ fs.existsSync(path.join(dir, 'Packages')) &&
12
+ fs.statSync(path.join(dir, 'Assets')).isDirectory() &&
13
+ fs.statSync(path.join(dir, 'Packages')).isDirectory()
14
+ );
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
19
+
20
+ const inferUnityProjectRootFromDir = startDir => {
21
+ try {
22
+ let dir = startDir;
23
+ const { root } = path.parse(dir);
24
+ while (true) {
25
+ if (looksLikeUnityProjectRoot(dir)) return dir;
26
+ if (dir === root) return null;
27
+ dir = path.dirname(dir);
28
+ }
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
6
34
  const resolveDefaultCodeIndexRoot = projectRoot => {
7
35
  const base = WORKSPACE_ROOT || projectRoot || process.cwd();
8
36
  return normalize(path.join(base, '.unity', 'cache', 'code-index'));
@@ -18,21 +46,6 @@ export class ProjectInfoProvider {
18
46
 
19
47
  async get() {
20
48
  if (this.cached) return this.cached;
21
- // Env-driven project root override (primarily for tests)
22
- const envRootRaw = process.env.UNITY_PROJECT_ROOT;
23
- if (typeof envRootRaw === 'string' && envRootRaw.trim().length > 0) {
24
- const envRoot = envRootRaw.trim();
25
- const projectRoot = normalize(path.resolve(envRoot));
26
- const codeIndexRoot = normalize(resolveDefaultCodeIndexRoot(projectRoot));
27
- this.cached = {
28
- projectRoot,
29
- assetsPath: normalize(path.join(projectRoot, 'Assets')),
30
- packagesPath: normalize(path.join(projectRoot, 'Packages')),
31
- codeIndexRoot
32
- };
33
- return this.cached;
34
- }
35
-
36
49
  // Config-driven project root (no env fallback)
37
50
  const cfgRootRaw = config?.project?.root;
38
51
  if (typeof cfgRootRaw === 'string' && cfgRootRaw.trim().length > 0) {
@@ -73,13 +86,28 @@ export class ProjectInfoProvider {
73
86
  logger.warning(`get_editor_info failed: ${e.message}`);
74
87
  }
75
88
  }
89
+
90
+ // Best-effort local inference (when Unity isn't connected)
91
+ const inferredRoot = inferUnityProjectRootFromDir(process.cwd());
92
+ if (inferredRoot) {
93
+ const projectRoot = normalize(inferredRoot);
94
+ const codeIndexRoot = normalize(resolveDefaultCodeIndexRoot(projectRoot));
95
+ this.cached = {
96
+ projectRoot,
97
+ assetsPath: normalize(path.join(projectRoot, 'Assets')),
98
+ packagesPath: normalize(path.join(projectRoot, 'Packages')),
99
+ codeIndexRoot
100
+ };
101
+ return this.cached;
102
+ }
103
+
76
104
  if (typeof cfgRootRaw === 'string') {
77
105
  throw new Error(
78
- 'project.root is configured but empty. Set a valid path in .unity/config.json or UNITY_MCP_CONFIG.'
106
+ 'project.root is configured but empty. Set a valid path in .unity/config.json.'
79
107
  );
80
108
  }
81
109
  throw new Error(
82
- 'Unable to resolve Unity project root. Configure project.root in .unity/config.json or provide UNITY_MCP_CONFIG.'
110
+ 'Unable to resolve Unity project root. Start the server inside a Unity project (directory containing Assets/ and Packages/) or configure project.root in .unity/config.json.'
83
111
  );
84
112
  }
85
113