@akiojin/unity-editor-mcp
Version:
MCP server for Unity Editor integration - enables AI assistants to control Unity Editor
120 lines (111 loc) • 4.31 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { ProjectInfoProvider } from './projectInfo.js';
export class CodeIndex {
constructor(unityConnection) {
this.unityConnection = unityConnection;
this.projectInfo = new ProjectInfoProvider(unityConnection);
this.db = null;
this.dbPath = null;
this.disabled = false; // set true if better-sqlite3 is unavailable
this._Database = null;
}
async _ensureDriver() {
if (this.disabled) return false;
if (this._Database) return true;
try {
// Dynamic import to avoid hard failure when native binding is missing
const mod = await import('better-sqlite3');
this._Database = mod.default || mod;
return true;
} catch (e) {
// Mark as disabled and operate in fallback (index unavailable)
this.disabled = true;
return false;
}
}
async open() {
if (this.db) return this.db;
const ok = await this._ensureDriver();
if (!ok) return null; // index disabled
const info = await this.projectInfo.get();
const dir = info.codeIndexRoot;
fs.mkdirSync(dir, { recursive: true });
const dbPath = path.join(dir, 'code-index.db');
this.dbPath = dbPath;
this.db = new this._Database(dbPath);
this._initSchema();
return this.db;
}
_initSchema() {
if (!this.db) return;
const db = this.db;
db.exec(`
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS symbols (
path TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL,
container TEXT,
namespace TEXT,
line INTEGER,
column INTEGER
);
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path);
`);
}
async isReady() {
const db = await this.open();
if (!db) return false;
const row = db.prepare('SELECT COUNT(*) AS c FROM symbols').get();
return (row?.c || 0) > 0;
}
async clearAndLoad(symbols) {
const db = await this.open();
if (!db) throw new Error('CodeIndex is unavailable (better-sqlite3 not installed)');
const insert = db.prepare('INSERT INTO symbols(path,name,kind,container,namespace,line,column) VALUES (?,?,?,?,?,?,?)');
const tx = db.transaction((rows) => {
db.exec('DELETE FROM symbols');
for (const r of rows) {
insert.run(r.path, r.name, r.kind, r.container || null, r.ns || r.namespace || null, r.line || null, r.column || null);
}
db.prepare('REPLACE INTO meta(key,value) VALUES (?,?)').run('lastIndexedAt', new Date().toISOString());
});
tx(symbols || []);
return { total: symbols?.length || 0 };
}
async querySymbols({ name, kind, scope = 'all', exact = false }) {
const db = await this.open();
if (!db) return [];
let sql = 'SELECT path,name,kind,container,namespace,line,column FROM symbols WHERE 1=1';
const params = {};
if (name) {
if (exact) { sql += ' AND name = @name'; params.name = name; }
else { sql += ' AND name LIKE @name'; params.name = `%${name}%`; }
}
if (kind) { sql += ' AND kind = @kind'; params.kind = kind; }
const rows = db.prepare(sql).all(params);
// Apply path-based scope filter in JS (simpler than CASE in SQL)
const filtered = rows.filter(r => {
const p = String(r.path || '').replace(/\\\\/g, '/');
if (scope === 'assets') return p.startsWith('Assets/');
if (scope === 'packages') return p.startsWith('Packages/') || p.includes('Library/PackageCache/');
if (scope === 'embedded') return p.startsWith('Packages/');
return true;
});
return filtered.map(r => ({ path: r.path, name: r.name, kind: r.kind, container: r.container, ns: r.namespace, line: r.line, column: r.column }));
}
async getStats() {
const db = await this.open();
if (!db) return { total: 0, lastIndexedAt: null };
const total = db.prepare('SELECT COUNT(*) AS c FROM symbols').get().c || 0;
const last = db.prepare("SELECT value AS v FROM meta WHERE key = 'lastIndexedAt'").get()?.v || null;
return { total, lastIndexedAt: last };
}
}