@akiojin/unity-mcp-server 4.2.1 → 5.1.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
@@ -72,6 +72,13 @@ More details:
72
72
  - Node.js 18.x / 20.x / 22.x LTS (23+ not supported)
73
73
  - MCP client (Claude Desktop, Cursor, etc.)
74
74
 
75
+ ## Native SQLite preload (optional)
76
+
77
+ The server uses `fast-sql`, which can preload a native `better-sqlite3` binding when a prebuilt binary is packaged.
78
+
79
+ - `UNITY_MCP_SKIP_NATIVE_BUILD=1` to skip native preload (forces sql.js fallback)
80
+ - `UNITY_MCP_FORCE_NATIVE=1` to require the prebuilt binary (install fails if missing)
81
+
75
82
  ## Troubleshooting
76
83
 
77
84
  - Troubleshooting index: [`docs/troubleshooting/README.md`](https://github.com/akiojin/unity-mcp-server/blob/main/docs/troubleshooting/README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "4.2.1",
3
+ "version": "5.1.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",
@@ -27,7 +27,7 @@
27
27
  "test:verbose": "VERBOSE_TEST=true node --test tests/**/*.test.js",
28
28
  "prepare": "cd .. && husky || true",
29
29
  "prepublishOnly": "npm run test:ci",
30
- "postinstall": "chmod +x bin/unity-mcp-server.js || true",
30
+ "postinstall": "node scripts/ensure-better-sqlite3.mjs",
31
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 tests/unit/core/startupPerformance.test.js || exit 0",
32
32
  "test:unity": "node tests/run-unity-integration.mjs",
33
33
  "test:nounity": "npm run test:integration",
@@ -68,6 +68,8 @@
68
68
  "files": [
69
69
  "src/",
70
70
  "bin/",
71
+ "scripts/",
72
+ "prebuilt/",
71
73
  "README.md",
72
74
  "LICENSE"
73
75
  ],
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+
7
+ const ABI_BY_NODE_MAJOR = new Map([
8
+ [18, 115],
9
+ [20, 120],
10
+ [22, 131]
11
+ ]);
12
+
13
+ export function parseEnvFlag(value) {
14
+ if (!value) return false;
15
+ const normalized = String(value).trim().toLowerCase();
16
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
17
+ }
18
+
19
+ export function resolveNodeMajor(nodeVersion) {
20
+ if (!nodeVersion) return null;
21
+ const major = Number.parseInt(String(nodeVersion).split('.')[0], 10);
22
+ return Number.isFinite(major) ? major : null;
23
+ }
24
+
25
+ export function resolveNodeAbi({ nodeVersion, nodeAbi } = {}) {
26
+ const abi = Number.parseInt(nodeAbi, 10);
27
+ if (Number.isFinite(abi)) return abi;
28
+ const major = resolveNodeMajor(nodeVersion);
29
+ if (!major) return null;
30
+ return ABI_BY_NODE_MAJOR.get(major) ?? null;
31
+ }
32
+
33
+ export function resolveRuntimeInfo({
34
+ env = process.env,
35
+ platform = process.platform,
36
+ arch = process.arch,
37
+ nodeVersion = process.versions?.node,
38
+ nodeAbi = process.versions?.modules
39
+ } = {}) {
40
+ const resolvedPlatform = env.UNITY_MCP_PLATFORM || platform;
41
+ const resolvedArch = env.UNITY_MCP_ARCH || arch;
42
+ const envNodeMajor = env.UNITY_MCP_NODE_MAJOR
43
+ ? Number.parseInt(env.UNITY_MCP_NODE_MAJOR, 10)
44
+ : null;
45
+ const resolvedNodeMajor = Number.isFinite(envNodeMajor)
46
+ ? envNodeMajor
47
+ : resolveNodeMajor(nodeVersion);
48
+ const envNodeAbi = env.UNITY_MCP_NODE_ABI ? Number.parseInt(env.UNITY_MCP_NODE_ABI, 10) : null;
49
+ const resolvedNodeAbi = Number.isFinite(envNodeAbi)
50
+ ? envNodeAbi
51
+ : resolveNodeAbi({ nodeVersion, nodeAbi });
52
+
53
+ return {
54
+ platform: resolvedPlatform,
55
+ arch: resolvedArch,
56
+ nodeMajor: resolvedNodeMajor,
57
+ nodeAbi: resolvedNodeAbi
58
+ };
59
+ }
60
+
61
+ export function buildPlatformKey({ platform, arch, nodeMajor }) {
62
+ if (!platform || !arch || !nodeMajor) return null;
63
+ return `${platform}-${arch}-node${nodeMajor}`;
64
+ }
65
+
66
+ export function resolvePackageRoot(env = process.env) {
67
+ if (env.UNITY_MCP_PACKAGE_ROOT) {
68
+ return path.resolve(env.UNITY_MCP_PACKAGE_ROOT);
69
+ }
70
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
71
+ return path.resolve(scriptDir, '..');
72
+ }
73
+
74
+ export function resolvePrebuiltRoot(packageRoot, env = process.env) {
75
+ if (env.UNITY_MCP_PREBUILT_DIR) {
76
+ return path.resolve(env.UNITY_MCP_PREBUILT_DIR);
77
+ }
78
+ return path.join(packageRoot, 'prebuilt', 'better-sqlite3');
79
+ }
80
+
81
+ export function resolveBetterSqlite3Root(packageRoot, env = process.env) {
82
+ if (env.UNITY_MCP_BETTER_SQLITE3_ROOT) {
83
+ return path.resolve(env.UNITY_MCP_BETTER_SQLITE3_ROOT);
84
+ }
85
+ try {
86
+ const req = createRequire(path.join(packageRoot, 'package.json'));
87
+ const pkgPath = req.resolve('better-sqlite3/package.json');
88
+ return path.dirname(pkgPath);
89
+ } catch {
90
+ return path.join(packageRoot, 'node_modules', 'better-sqlite3');
91
+ }
92
+ }
93
+
94
+ export function resolvePrebuiltBindingPath(prebuiltRoot, platformKey) {
95
+ if (!prebuiltRoot || !platformKey) return null;
96
+ return path.join(prebuiltRoot, platformKey, 'better_sqlite3.node');
97
+ }
98
+
99
+ export function resolveInstalledBindingPath(moduleRoot) {
100
+ return path.join(moduleRoot, 'build', 'Release', 'better_sqlite3.node');
101
+ }
102
+
103
+ export function ensureBinExecutable({ packageRoot, env = process.env, logger = console } = {}) {
104
+ if (parseEnvFlag(env.UNITY_MCP_SKIP_BIN_CHMOD)) return false;
105
+ const binPath = path.join(packageRoot, 'bin', 'unity-mcp-server.js');
106
+ try {
107
+ if (fs.existsSync(binPath)) {
108
+ fs.chmodSync(binPath, 0o755);
109
+ logger.info(`[ensure-better-sqlite3] chmod +x ${binPath}`);
110
+ return true;
111
+ }
112
+ } catch (err) {
113
+ logger.warn(
114
+ `[ensure-better-sqlite3] Failed to chmod ${binPath}: ${err?.message || String(err)}`
115
+ );
116
+ }
117
+ return false;
118
+ }
119
+
120
+ export function ensureBetterSqlite3({
121
+ env = process.env,
122
+ logger = console,
123
+ packageRoot,
124
+ prebuiltRoot,
125
+ moduleRoot,
126
+ platform,
127
+ arch,
128
+ nodeVersion,
129
+ nodeAbi
130
+ } = {}) {
131
+ const skipNative = parseEnvFlag(env.UNITY_MCP_SKIP_NATIVE_BUILD);
132
+ const forceNative = parseEnvFlag(env.UNITY_MCP_FORCE_NATIVE);
133
+
134
+ const resolvedPackageRoot = packageRoot || resolvePackageRoot(env);
135
+ const resolvedPrebuiltRoot = prebuiltRoot || resolvePrebuiltRoot(resolvedPackageRoot, env);
136
+ const resolvedModuleRoot = moduleRoot || resolveBetterSqlite3Root(resolvedPackageRoot, env);
137
+
138
+ if (skipNative) {
139
+ logger.info('[ensure-better-sqlite3] Native preload skipped (UNITY_MCP_SKIP_NATIVE_BUILD).');
140
+ ensureBinExecutable({ packageRoot: resolvedPackageRoot, env, logger });
141
+ return { action: 'skip' };
142
+ }
143
+
144
+ if (!fs.existsSync(resolvedModuleRoot)) {
145
+ const message = `[ensure-better-sqlite3] better-sqlite3 module not found at ${resolvedModuleRoot}`;
146
+ if (forceNative) {
147
+ throw new Error(message);
148
+ }
149
+ logger.warn(message);
150
+ ensureBinExecutable({ packageRoot: resolvedPackageRoot, env, logger });
151
+ return { action: 'no-module' };
152
+ }
153
+
154
+ const runtimeInfo = resolveRuntimeInfo({
155
+ env,
156
+ platform,
157
+ arch,
158
+ nodeVersion,
159
+ nodeAbi
160
+ });
161
+
162
+ const platformKey = buildPlatformKey(runtimeInfo);
163
+ if (!platformKey) {
164
+ const message = '[ensure-better-sqlite3] Unable to resolve platform key';
165
+ if (forceNative) throw new Error(message);
166
+ logger.warn(message);
167
+ ensureBinExecutable({ packageRoot: resolvedPackageRoot, env, logger });
168
+ return { action: 'fallback' };
169
+ }
170
+
171
+ const prebuiltPath = resolvePrebuiltBindingPath(resolvedPrebuiltRoot, platformKey);
172
+ if (!prebuiltPath || !fs.existsSync(prebuiltPath)) {
173
+ const message = `[ensure-better-sqlite3] Prebuilt binary not found for ${platformKey}`;
174
+ if (forceNative) throw new Error(message);
175
+ logger.info(message);
176
+ ensureBinExecutable({ packageRoot: resolvedPackageRoot, env, logger });
177
+ return { action: 'fallback', platformKey, prebuiltPath };
178
+ }
179
+
180
+ const destPath = resolveInstalledBindingPath(resolvedModuleRoot);
181
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
182
+ fs.copyFileSync(prebuiltPath, destPath);
183
+ logger.info(`[ensure-better-sqlite3] Installed prebuilt ${platformKey} -> ${destPath}`);
184
+
185
+ ensureBinExecutable({ packageRoot: resolvedPackageRoot, env, logger });
186
+ return { action: 'copied', platformKey, prebuiltPath, destPath };
187
+ }
188
+
189
+ function isCliInvocation() {
190
+ if (!process.argv[1]) return false;
191
+ const invoked = path.resolve(process.argv[1]);
192
+ const current = path.resolve(fileURLToPath(import.meta.url));
193
+ return invoked === current;
194
+ }
195
+
196
+ if (isCliInvocation()) {
197
+ try {
198
+ ensureBetterSqlite3();
199
+ } catch (err) {
200
+ const message = err?.message || String(err);
201
+ console.error(`[ensure-better-sqlite3] ${message}`);
202
+ process.exit(1);
203
+ }
204
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { spawn } from 'child_process';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const repoRoot = path.resolve(__dirname, '..');
10
+ const integrationRoot = path.join(repoRoot, 'tests', 'integration');
11
+
12
+ const curatedUnitTests = [
13
+ 'tests/unit/core/codeIndex.test.js',
14
+ 'tests/unit/core/config.test.js',
15
+ 'tests/unit/core/indexWatcher.test.js',
16
+ 'tests/unit/core/projectInfo.test.js',
17
+ 'tests/unit/core/server.test.js',
18
+ 'tests/unit/handlers/script/CodeIndexStatusToolHandler.test.js'
19
+ ];
20
+
21
+ function collectIntegrationTests(dir) {
22
+ const entries = fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }) : [];
23
+ const files = [];
24
+ for (const entry of entries) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ files.push(...collectIntegrationTests(full));
28
+ } else if (entry.isFile() && entry.name.endsWith('.test.js')) {
29
+ files.push(full);
30
+ }
31
+ }
32
+ return files;
33
+ }
34
+
35
+ function detectUnityDependency(filePath) {
36
+ try {
37
+ const content = fs.readFileSync(filePath, 'utf8');
38
+ return /UnityConnection|connect\(\)\s*=>\s*new UnityConnection/i.test(content);
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ const allIntegrationTests = collectIntegrationTests(integrationRoot);
45
+ const nonUnityTests = [];
46
+ const unityTests = [];
47
+
48
+ for (const absPath of allIntegrationTests) {
49
+ const relPath = path.relative(repoRoot, absPath);
50
+ if (detectUnityDependency(absPath)) {
51
+ unityTests.push(relPath);
52
+ } else {
53
+ nonUnityTests.push(relPath);
54
+ }
55
+ }
56
+
57
+ if (nonUnityTests.length === 0) {
58
+ console.warn('[tests] No non-Unity integration tests found.');
59
+ }
60
+
61
+ if (unityTests.length > 0) {
62
+ console.log('[tests] Skipping Unity-dependent integration suites in this run:');
63
+ for (const rel of unityTests) {
64
+ console.log(` - ${rel}`);
65
+ }
66
+ console.log(
67
+ '[tests] These suites are executed via `npm run test:unity --workspace=mcp-server` when a Unity Editor is available.'
68
+ );
69
+ }
70
+
71
+ const testTargets = [...curatedUnitTests, ...nonUnityTests];
72
+
73
+ const child = spawn(process.execPath, ['--test', ...testTargets], {
74
+ stdio: 'inherit',
75
+ cwd: repoRoot
76
+ });
77
+
78
+ child.on('exit', code => {
79
+ process.exit(code ?? 1);
80
+ });
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ import { CodeIndexBuildToolHandler } from '../src/handlers/script/CodeIndexBuildToolHandler.js';
7
+ import { CodeIndexStatusToolHandler } from '../src/handlers/script/CodeIndexStatusToolHandler.js';
8
+ import { ProjectInfoProvider } from '../src/core/projectInfo.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ const argv = process.argv.slice(2);
13
+ const options = {
14
+ throttleMs: 50,
15
+ pollMs: 500,
16
+ reset: false,
17
+ delayStartMs: 0
18
+ };
19
+
20
+ for (const arg of argv) {
21
+ if (arg.startsWith('--throttle=')) {
22
+ options.throttleMs = Math.max(0, Number(arg.split('=')[1] || 0));
23
+ } else if (arg.startsWith('--poll=')) {
24
+ options.pollMs = Math.max(100, Number(arg.split('=')[1] || 500));
25
+ } else if (arg.startsWith('--delayStart=')) {
26
+ options.delayStartMs = Math.max(0, Number(arg.split('=')[1] || 0));
27
+ } else if (arg === '--reset' || arg === '--clean') {
28
+ options.reset = true;
29
+ } else if (arg === '--help' || arg === '-h') {
30
+ printHelp();
31
+ process.exit(0);
32
+ }
33
+ }
34
+
35
+ function printHelp() {
36
+ console.log(`Usage: node scripts/simulate-code-index-status.mjs [--throttle=MS] [--poll=MS] [--reset]
37
+
38
+ Options:
39
+ --throttle=MS Delay (ms) after each file during build to keep job running (default: 50)
40
+ --poll=MS Poll interval for code_index_status (default: 500)
41
+ --delayStart=MS Delay (ms) before processing begins (default: 0)
42
+ --reset Delete existing code index database before running
43
+ `);
44
+ }
45
+
46
+ const mockUnityConnection = {
47
+ isConnected() {
48
+ return false;
49
+ },
50
+ sendCommand() {
51
+ throw new Error('Unity connection not available in simulation');
52
+ }
53
+ };
54
+
55
+ const buildHandler = new CodeIndexBuildToolHandler(mockUnityConnection);
56
+ const statusHandler = new CodeIndexStatusToolHandler(mockUnityConnection);
57
+ const projectInfo = new ProjectInfoProvider(mockUnityConnection);
58
+
59
+ (async () => {
60
+ const info = await projectInfo.get();
61
+ const dbDir = path.resolve(info.codeIndexRoot);
62
+ if (options.reset) {
63
+ try {
64
+ for (const file of ['code-index.db', 'code-index.db-shm', 'code-index.db-wal']) {
65
+ const target = path.join(dbDir, file);
66
+ if (fs.existsSync(target)) fs.unlinkSync(target);
67
+ }
68
+ console.log('[simulate] Existing code index removed.');
69
+ } catch (err) {
70
+ console.error('[simulate] Failed to clean code index:', err.message);
71
+ }
72
+ }
73
+
74
+ console.log('[simulate] Starting code_index_build with throttleMs =', options.throttleMs);
75
+ const buildResult = await buildHandler.execute({
76
+ throttleMs: options.throttleMs,
77
+ delayStartMs: options.delayStartMs
78
+ });
79
+ console.log('[simulate] Job ID:', buildResult.jobId);
80
+
81
+ const interval = setInterval(async () => {
82
+ try {
83
+ const status = await statusHandler.execute({});
84
+ const job = status.index?.buildJob;
85
+ const line = {
86
+ time: new Date().toISOString(),
87
+ ready: status.index?.ready,
88
+ rows: status.index?.rows,
89
+ jobStatus: job?.status ?? null,
90
+ processed: job?.progress?.processed ?? null,
91
+ total: job?.progress?.total ?? null
92
+ };
93
+ console.log('[status]', JSON.stringify(line));
94
+
95
+ if (!job || job.status !== 'running') {
96
+ clearInterval(interval);
97
+ console.log('[simulate] Build finished.');
98
+ process.exit(0);
99
+ }
100
+ } catch (err) {
101
+ console.error('[status]', err.message);
102
+ }
103
+ }, options.pollMs);
104
+ })();
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Sync Unity Package version with mcp-server version
5
+ * Called by release-please via extra-files or manual invocation
6
+ *
7
+ * Usage: node sync-unity-package-version.js <version>
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
17
+ // Get version from command line argument
18
+ const version = process.argv[2];
19
+
20
+ if (!version) {
21
+ console.error('Error: Version argument is required');
22
+ console.error('Usage: node sync-unity-package-version.js <version>');
23
+ process.exit(1);
24
+ }
25
+
26
+ // Resolve Unity Package path (mcp-server and UnityMCPServer are siblings)
27
+ const unityPackageJsonPath = path.join(
28
+ __dirname,
29
+ '../../UnityMCPServer/Packages/unity-mcp-server/package.json'
30
+ );
31
+
32
+ try {
33
+ // Read Unity package.json
34
+ const packageJsonContent = fs.readFileSync(unityPackageJsonPath, 'utf8');
35
+ const packageJson = JSON.parse(packageJsonContent);
36
+
37
+ // Update version
38
+ packageJson.version = version;
39
+
40
+ // Write back with 2-space indentation and trailing newline
41
+ const updatedContent = JSON.stringify(packageJson, null, 2) + '\n';
42
+ fs.writeFileSync(unityPackageJsonPath, updatedContent, 'utf8');
43
+
44
+ console.log(`Unity Package version synced to ${version}`);
45
+ process.exit(0);
46
+ } catch (error) {
47
+ console.error(`Error: Failed to sync Unity Package version`);
48
+ console.error(error.message);
49
+ process.exit(1);
50
+ }
@@ -153,7 +153,7 @@ const baseConfig = {
153
153
 
154
154
  // LSP client defaults
155
155
  lsp: {
156
- requestTimeoutMs: 60000
156
+ requestTimeoutMs: 120000
157
157
  },
158
158
 
159
159
  // Indexing (code index) settings
@@ -649,10 +649,9 @@ function wrapUnityConnectError(error, host, port) {
649
649
  }
650
650
 
651
651
  function buildUnityConnectionHint(_host, _port) {
652
- const configPath = config.__configPath || '.unity/config.json';
653
652
  return (
654
653
  `Start Unity Editor and ensure the Unity MCP package is running (TCP listener). ` +
655
- `Check ${configPath} (unity.mcpHost/unity.port). ` +
656
- `If using WSL2/Docker → Windows Unity, set unity.mcpHost=host.docker.internal.`
654
+ `Check UNITY_MCP_MCP_HOST / UNITY_MCP_PORT and Unity Project Settings (Host/Port). ` +
655
+ `If using WSL2/Docker → Windows Unity, set UNITY_MCP_MCP_HOST=host.docker.internal.`
657
656
  );
658
657
  }
@@ -30,6 +30,11 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
30
30
  description:
31
31
  'If true, run validation and return preview text without writing to disk. Default=false.'
32
32
  },
33
+ skipValidation: {
34
+ type: 'boolean',
35
+ description:
36
+ 'If true, skip LSP validation for faster execution. Lightweight syntax checks (brace balance) are still performed. Use for simple edits on large files. Default=false.'
37
+ },
33
38
  instructions: {
34
39
  type: 'array',
35
40
  minItems: 1,
@@ -114,6 +119,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
114
119
  const info = await this.projectInfo.get();
115
120
  const { relative, absolute } = this.#resolvePaths(info, params.path);
116
121
  const preview = params.preview === true;
122
+ const skipValidation = params.skipValidation === true;
117
123
  const instructions = params.instructions;
118
124
 
119
125
  let original;
@@ -142,7 +148,13 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
142
148
  }
143
149
 
144
150
  if (working === original) {
145
- return this.#buildResponse({ preview, results, original, updated: working });
151
+ return this.#buildResponse({
152
+ preview,
153
+ results,
154
+ original,
155
+ updated: working,
156
+ validationSkipped: skipValidation
157
+ });
146
158
  }
147
159
 
148
160
  // Pre-syntax check on edited content before LSP validation
@@ -154,19 +166,30 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
154
166
  );
155
167
  }
156
168
 
157
- const diagnostics = await this.#validateWithLsp(info, relative, working);
158
- const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
159
- if (hasErrors) {
160
- const first = diagnostics.find(d => this.#severityIsError(d.severity));
161
- const msg = first?.message || 'syntax error';
162
- throw new Error(`syntax_error: ${msg}`);
169
+ // LSP validation (skip if skipValidation=true for large files)
170
+ let diagnostics = [];
171
+ if (!skipValidation) {
172
+ diagnostics = await this.#validateWithLsp(info, relative, working);
173
+ const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
174
+ if (hasErrors) {
175
+ const first = diagnostics.find(d => this.#severityIsError(d.severity));
176
+ const msg = first?.message || 'syntax error';
177
+ throw new Error(`syntax_error: ${msg}`);
178
+ }
163
179
  }
164
180
 
165
181
  if (!preview) {
166
182
  await fs.writeFile(absolute, working, 'utf8');
167
183
  }
168
184
 
169
- return this.#buildResponse({ preview, results, original, updated: working, diagnostics });
185
+ return this.#buildResponse({
186
+ preview,
187
+ results,
188
+ original,
189
+ updated: working,
190
+ diagnostics,
191
+ validationSkipped: skipValidation
192
+ });
170
193
  }
171
194
 
172
195
  #resolvePaths(info, rawPath) {
@@ -279,12 +302,20 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
279
302
  return await this.lsp.validateText(relative, updatedText);
280
303
  }
281
304
 
282
- #buildResponse({ preview, results, original, updated, diagnostics = [] }) {
305
+ #buildResponse({
306
+ preview,
307
+ results,
308
+ original,
309
+ updated,
310
+ diagnostics = [],
311
+ validationSkipped = false
312
+ }) {
283
313
  const out = {
284
314
  success: true,
285
315
  applied: !preview,
286
316
  results,
287
317
  diagnostics,
318
+ validationSkipped,
288
319
  beforeHash: this.#hash(original),
289
320
  afterHash: this.#hash(updated)
290
321
  };