@akiojin/unity-mcp-server 2.41.7 → 2.42.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,7 +1,6 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { CodeIndex } from '../../core/codeIndex.js';
3
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
4
- import { ProjectInfoProvider } from '../../core/projectInfo.js';
3
+ import { JobManager } from '../../core/jobManager.js';
5
4
 
6
5
  export class ScriptSymbolFindToolHandler extends BaseToolHandler {
7
6
  constructor(unityConnection) {
@@ -36,8 +35,7 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
36
35
  );
37
36
  this.unityConnection = unityConnection;
38
37
  this.index = new CodeIndex(unityConnection);
39
- this.projectInfo = new ProjectInfoProvider(unityConnection);
40
- this.lsp = null;
38
+ this.jobManager = JobManager.getInstance();
41
39
  }
42
40
 
43
41
  validate(params) {
@@ -52,52 +50,72 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
52
50
 
53
51
  async execute(params) {
54
52
  const { name, kind, scope = 'assets', exact = false } = params;
55
- // Prefer persistent index if available
56
- let results = [];
57
- if (await this.index.isReady()) {
58
- const rows = await this.index.querySymbols({ name, kind, scope, exact });
59
- results = rows.map(r => ({
60
- // Index returns project-relative paths already
61
- path: (r.path || '').replace(/\\\\/g, '/'),
62
- symbol: {
63
- name: r.name,
64
- kind: r.kind,
65
- namespace: r.ns,
66
- container: r.container,
67
- startLine: r.line,
68
- startColumn: r.column,
69
- endLine: r.line,
70
- endColumn: r.column
71
- }
72
- }));
73
- } else {
74
- const info = await this.projectInfo.get();
75
- if (!this.lsp) this.lsp = new LspRpcClient(info.projectRoot);
76
- const resp = await this.lsp.request('workspace/symbol', { query: String(name) });
77
- const arr = resp?.result || [];
78
- const root = String(info.projectRoot || '').replace(/\\\\/g, '/');
79
- const rootWithSlash = root.endsWith('/') ? root : root + '/';
80
- results = arr.map(s => {
81
- const uri = String(s.location?.uri || '');
82
- // Normalize to absolute path without scheme
83
- const abs = uri.replace('file://', '').replace(/\\\\/g, '/');
84
- // Convert to project-relative if under project root
85
- const rel = abs.startsWith(rootWithSlash) ? abs.slice(rootWithSlash.length) : abs;
53
+
54
+ // Check if code index is ready - no fallback to LSP
55
+ const ready = await this.index.isReady();
56
+ if (this.index.disabled) {
57
+ return {
58
+ success: false,
59
+ error: 'code_index_unavailable',
60
+ message:
61
+ this.index.disableReason ||
62
+ 'Code index is disabled because the SQLite driver could not be loaded.',
63
+ remediation:
64
+ 'Install native build tools and run "npm rebuild better-sqlite3 --build-from-source", then restart the server.'
65
+ };
66
+ }
67
+
68
+ if (!ready) {
69
+ // Check if a build job is currently running
70
+ const allJobs = this.jobManager.getAllJobs();
71
+ const buildJob = allJobs.find(
72
+ job =>
73
+ (job.id.startsWith('build-') || job.id.startsWith('watcher-')) && job.status === 'running'
74
+ );
75
+
76
+ if (buildJob) {
77
+ const progress = buildJob.progress || {};
78
+ const pct =
79
+ progress.total > 0 ? Math.round((progress.processed / progress.total) * 100) : 0;
86
80
  return {
87
- path: rel,
88
- symbol: {
89
- name: s.name,
90
- kind: this.mapKind(s.kind),
91
- namespace: null,
92
- container: null,
93
- startLine: (s.location?.range?.start?.line ?? 0) + 1,
94
- startColumn: (s.location?.range?.start?.character ?? 0) + 1,
95
- endLine: (s.location?.range?.end?.line ?? 0) + 1,
96
- endColumn: (s.location?.range?.end?.character ?? 0) + 1
81
+ success: false,
82
+ error: 'index_building',
83
+ message: `Code index is currently being built. Please wait and retry. Progress: ${pct}% (${progress.processed || 0}/${progress.total || 0})`,
84
+ jobId: buildJob.id,
85
+ progress: {
86
+ processed: progress.processed || 0,
87
+ total: progress.total || 0,
88
+ percentage: pct
97
89
  }
98
90
  };
99
- });
91
+ }
92
+
93
+ // No build job running - index not available
94
+ return {
95
+ success: false,
96
+ error: 'index_not_ready',
97
+ message:
98
+ 'Code index is not built. Run code_index_build first, or wait for auto-build to complete on server startup.',
99
+ hint: 'Use code_index_status to check index state, or code_index_build to start a build manually.'
100
+ };
100
101
  }
102
+
103
+ // DB index is ready - use it
104
+ const rows = await this.index.querySymbols({ name, kind, scope, exact });
105
+ let results = rows.map(r => ({
106
+ // Index returns project-relative paths already
107
+ path: (r.path || '').replace(/\\\\/g, '/'),
108
+ symbol: {
109
+ name: r.name,
110
+ kind: r.kind,
111
+ namespace: r.ns,
112
+ container: r.container,
113
+ startLine: r.line,
114
+ startColumn: r.column,
115
+ endLine: r.line,
116
+ endColumn: r.column
117
+ }
118
+ }));
101
119
  // Optional post-filtering: scope and exact name
102
120
  if (scope && scope !== 'all') {
103
121
  results = results.filter(x => {
@@ -120,23 +138,4 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
120
138
  }
121
139
  return { success: true, results, total: results.length };
122
140
  }
123
-
124
- mapKind(k) {
125
- switch (k) {
126
- case 5:
127
- return 'class';
128
- case 23:
129
- return 'struct';
130
- case 11:
131
- return 'interface';
132
- case 10:
133
- return 'enum';
134
- case 6:
135
- return 'method';
136
- case 7:
137
- return 'property';
138
- case 8:
139
- return 'field';
140
- }
141
- }
142
141
  }
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
4
4
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
5
6
 
6
7
  export class ScriptSymbolsGetToolHandler extends BaseToolHandler {
7
8
  constructor(unityConnection) {
@@ -55,8 +56,7 @@ export class ScriptSymbolsGetToolHandler extends BaseToolHandler {
55
56
  const abs = path.join(info.projectRoot, relPath);
56
57
  const st = await fs.stat(abs).catch(() => null);
57
58
  if (!st || !st.isFile()) return { error: 'File not found', path: relPath };
58
- const { LspRpcClient } = await import('../../lsp/LspRpcClient.js');
59
- const lsp = new LspRpcClient(info.projectRoot);
59
+ const lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
60
60
  const uri = 'file://' + abs.replace(/\\\\/g, '/');
61
61
  const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
62
62
  const docSymbols = res?.result ?? res ?? [];
@@ -51,6 +51,15 @@ export class LspProcessManager {
51
51
  if (!this.state.proc || this.state.proc.killed) return;
52
52
  const p = this.state.proc;
53
53
  this.state.proc = null;
54
+
55
+ // Remove all listeners to prevent memory leaks
56
+ try {
57
+ if (p.stdout) p.stdout.removeAllListeners();
58
+ if (p.stderr) p.stderr.removeAllListeners();
59
+ p.removeAllListeners('error');
60
+ p.removeAllListeners('close');
61
+ } catch {}
62
+
54
63
  try {
55
64
  // Send LSP shutdown/exit if possible
56
65
  const shutdown = obj => {
@@ -67,12 +67,19 @@ export class LspRpcClient {
67
67
  if (!this.proc || this.proc.killed) {
68
68
  throw new Error('LSP process not available');
69
69
  }
70
+ // Check if stdin is still writable
71
+ if (!this.proc.stdin || this.proc.stdin.destroyed || this.proc.stdin.writableEnded) {
72
+ throw new Error('LSP stdin not writable');
73
+ }
70
74
  const json = JSON.stringify(obj);
71
75
  const payload = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
72
76
  try {
73
77
  this.proc.stdin.write(payload, 'utf8');
74
78
  } catch (e) {
75
79
  logger.error(`[csharp-lsp] writeMessage failed: ${e.message}`);
80
+ // Mark process as unavailable to prevent further writes
81
+ this.proc = null;
82
+ this.initialized = false;
76
83
  throw e;
77
84
  }
78
85
  }
@@ -0,0 +1,98 @@
1
+ import { LspRpcClient } from './LspRpcClient.js';
2
+ import { logger } from '../core/config.js';
3
+
4
+ /**
5
+ * Singleton manager for LspRpcClient instances.
6
+ * Maintains one client per projectRoot to enable process reuse and improve performance.
7
+ */
8
+
9
+ let instance = null;
10
+ let currentProjectRoot = null;
11
+ let heartbeatTimer = null;
12
+
13
+ export class LspRpcClientSingleton {
14
+ /**
15
+ * Get or create a shared LspRpcClient instance.
16
+ * @param {string} projectRoot - Project root path
17
+ * @returns {Promise<LspRpcClient>} Shared client instance
18
+ */
19
+ static async getInstance(projectRoot) {
20
+ // If projectRoot changed, reset the instance
21
+ if (instance && currentProjectRoot !== projectRoot) {
22
+ logger.info('[LspRpcClientSingleton] projectRoot changed, resetting instance');
23
+ await LspRpcClientSingleton.reset();
24
+ }
25
+
26
+ if (!instance) {
27
+ instance = new LspRpcClient(projectRoot);
28
+ currentProjectRoot = projectRoot;
29
+ logger.info(`[LspRpcClientSingleton] created new instance for ${projectRoot}`);
30
+ LspRpcClientSingleton.#startHeartbeat();
31
+ }
32
+
33
+ return instance;
34
+ }
35
+
36
+ /**
37
+ * Reset the singleton instance and stop the process.
38
+ */
39
+ static async reset() {
40
+ LspRpcClientSingleton.#stopHeartbeat();
41
+ if (instance) {
42
+ try {
43
+ await instance.mgr.stop();
44
+ } catch (e) {
45
+ logger.warn(`[LspRpcClientSingleton] error stopping: ${e.message}`);
46
+ }
47
+ instance = null;
48
+ currentProjectRoot = null;
49
+ logger.info('[LspRpcClientSingleton] instance reset');
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Start heartbeat monitoring to detect dead processes.
55
+ */
56
+ static #startHeartbeat() {
57
+ if (heartbeatTimer) return;
58
+ heartbeatTimer = setInterval(async () => {
59
+ if (!instance) return;
60
+ // Check if process is still alive before attempting heartbeat
61
+ if (!instance.proc || instance.proc.killed) {
62
+ logger.warn('[LspRpcClientSingleton] process dead, resetting...');
63
+ instance = null;
64
+ currentProjectRoot = null;
65
+ LspRpcClientSingleton.#stopHeartbeat();
66
+ return;
67
+ }
68
+ try {
69
+ // Use workspace/symbol with empty query as a lightweight ping
70
+ await instance.request('workspace/symbol', { query: '' });
71
+ } catch (e) {
72
+ logger.warn(`[LspRpcClientSingleton] heartbeat failed: ${e.message}, resetting...`);
73
+ // Process is dead, reset instance for next request
74
+ instance = null;
75
+ currentProjectRoot = null;
76
+ LspRpcClientSingleton.#stopHeartbeat();
77
+ }
78
+ }, 30000); // 30 seconds
79
+ }
80
+
81
+ /**
82
+ * Stop heartbeat monitoring.
83
+ */
84
+ static #stopHeartbeat() {
85
+ if (heartbeatTimer) {
86
+ clearInterval(heartbeatTimer);
87
+ heartbeatTimer = null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if an instance exists.
93
+ * @returns {boolean}
94
+ */
95
+ static hasInstance() {
96
+ return instance !== null;
97
+ }
98
+ }
@@ -1,112 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import Database from 'better-sqlite3';
4
- let dbCache = new Map();
5
-
6
- function getDbPath(projectRoot) {
7
- const dir = path.join(projectRoot, 'Library', 'UnityMCP', 'CodeIndex');
8
- fs.mkdirSync(dir, { recursive: true });
9
- return path.join(dir, 'index.db');
10
- }
11
-
12
- export function openDb(projectRoot) {
13
- const key = path.resolve(projectRoot);
14
- if (dbCache.has(key)) return dbCache.get(key);
15
- const dbPath = getDbPath(projectRoot);
16
- const db = new Database(dbPath);
17
- db.pragma('journal_mode = WAL');
18
- db.exec(`
19
- CREATE TABLE IF NOT EXISTS files (
20
- path TEXT PRIMARY KEY,
21
- mtime INTEGER NOT NULL
22
- );
23
- CREATE TABLE IF NOT EXISTS symbols (
24
- path TEXT NOT NULL,
25
- name TEXT NOT NULL,
26
- kind TEXT,
27
- container TEXT,
28
- ns TEXT,
29
- line INTEGER,
30
- column INTEGER,
31
- FOREIGN KEY(path) REFERENCES files(path)
32
- );
33
- CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
34
- CREATE TABLE IF NOT EXISTS refs (
35
- path TEXT NOT NULL,
36
- name TEXT NOT NULL,
37
- line INTEGER,
38
- snippet TEXT,
39
- FOREIGN KEY(path) REFERENCES files(path)
40
- );
41
- CREATE INDEX IF NOT EXISTS idx_refs_name ON refs(name);
42
- `);
43
- dbCache.set(key, db);
44
- return db;
45
- }
46
-
47
- export function upsertFile(db, filePath, mtimeMs) {
48
- const stmt = db.prepare(
49
- 'INSERT INTO files(path, mtime) VALUES(?, ?) ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime'
50
- );
51
- stmt.run(filePath, Math.floor(mtimeMs));
52
- }
53
-
54
- export function replaceSymbols(db, filePath, symbols) {
55
- const del = db.prepare('DELETE FROM symbols WHERE path = ?');
56
- del.run(filePath);
57
- const ins = db.prepare(
58
- 'INSERT INTO symbols(path, name, kind, container, ns, line, column) VALUES(?,?,?,?,?,?,?)'
59
- );
60
- const tr = db.transaction(rows => {
61
- for (const s of rows)
62
- ins.run(
63
- filePath,
64
- s.name || '',
65
- s.kind || '',
66
- s.container || null,
67
- s.ns || null,
68
- s.line || 0,
69
- s.column || 0
70
- );
71
- });
72
- tr(symbols || []);
73
- }
74
-
75
- export function replaceReferences(db, filePath, refs) {
76
- const del = db.prepare('DELETE FROM refs WHERE path = ?');
77
- del.run(filePath);
78
- const ins = db.prepare('INSERT INTO refs(path, name, line, snippet) VALUES(?,?,?,?)');
79
- const tr = db.transaction(rows => {
80
- for (const r of rows) ins.run(filePath, r.name || '', r.line || 0, r.snippet || null);
81
- });
82
- tr(refs || []);
83
- }
84
-
85
- export function querySymbolsByName(db, name, kind = null) {
86
- if (kind) {
87
- return db
88
- .prepare(
89
- 'SELECT path,name,kind,container,ns,line,column FROM symbols WHERE name = ? AND kind = ? LIMIT 500'
90
- )
91
- .all(name, kind);
92
- }
93
- return db
94
- .prepare('SELECT path,name,kind,container,ns,line,column FROM symbols WHERE name = ? LIMIT 500')
95
- .all(name);
96
- }
97
-
98
- export function queryRefsByName(db, name) {
99
- return db.prepare('SELECT path,name,line,snippet FROM refs WHERE name = ? LIMIT 1000').all(name);
100
- }
101
-
102
- export function isFresh(projectRoot, filePath, db) {
103
- try {
104
- const row = db.prepare('SELECT mtime FROM files WHERE path = ?').get(filePath);
105
- if (!row) return false;
106
- const abs = path.join(projectRoot, filePath);
107
- const st = fs.statSync(abs);
108
- return Math.floor(st.mtimeMs) === row.mtime;
109
- } catch {
110
- return false;
111
- }
112
- }
@@ -1,82 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { createRequire } from 'module';
4
- import initSqlJs from 'sql.js';
5
-
6
- // Create a lightweight better-sqlite3 compatible surface using sql.js (WASM)
7
- export async function createSqliteFallback(dbPath) {
8
- // Use Node's module resolution to find sql.js regardless of package manager (npm, pnpm, yarn)
9
- // require.resolve('sql.js') returns the main entry point (dist/sql-wasm.js)
10
- // so we just need sql-wasm.wasm in the same directory
11
- const require = createRequire(import.meta.url);
12
- const sqlJsPath = require.resolve('sql.js');
13
- const sqlJsDir = path.dirname(sqlJsPath);
14
- const wasmPath = path.resolve(sqlJsDir, 'sql-wasm.wasm');
15
- const SQL = await initSqlJs({ locateFile: () => wasmPath });
16
-
17
- const loadDb = () => {
18
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
19
- if (fs.existsSync(dbPath)) {
20
- const data = fs.readFileSync(dbPath);
21
- return new SQL.Database(new Uint8Array(data));
22
- }
23
- return new SQL.Database();
24
- };
25
-
26
- const db = loadDb();
27
-
28
- const persist = () => {
29
- const data = db.export();
30
- fs.writeFileSync(dbPath, Buffer.from(data));
31
- };
32
-
33
- // Wrap sql.js Statement to look like better-sqlite3's
34
- const wrapStatement = stmt => ({
35
- run(...params) {
36
- stmt.bind(params);
37
- // sql.js run via stepping through the statement
38
- while (stmt.step()) {
39
- /* consume rows for statements that return data */
40
- }
41
- stmt.reset();
42
- persist();
43
- return this;
44
- },
45
- get(...params) {
46
- stmt.bind(params);
47
- const has = stmt.step();
48
- const row = has ? stmt.getAsObject() : undefined;
49
- stmt.reset();
50
- return row;
51
- },
52
- all(...params) {
53
- stmt.bind(params);
54
- const rows = [];
55
- while (stmt.step()) rows.push(stmt.getAsObject());
56
- stmt.reset();
57
- return rows;
58
- }
59
- });
60
-
61
- const prepare = sql => wrapStatement(db.prepare(sql));
62
-
63
- // Mimic better-sqlite3 transaction(fn)
64
- const transaction =
65
- fn =>
66
- (...args) => {
67
- const result = fn(...args);
68
- persist();
69
- return result;
70
- };
71
-
72
- // Minimal surface used by CodeIndex
73
- return {
74
- exec: sql => {
75
- db.exec(sql);
76
- persist();
77
- },
78
- prepare,
79
- transaction,
80
- persist
81
- };
82
- }