@akiojin/unity-mcp-server 4.1.4 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "4.1.4",
3
+ "version": "4.2.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",
@@ -4,6 +4,7 @@ import crypto from 'crypto';
4
4
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
6
  import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
+ import { preSyntaxCheck } from './csharpSyntaxCheck.js';
7
8
 
8
9
  const MAX_INSTRUCTIONS = 10;
9
10
  const MAX_DIFF_CHARS = 80;
@@ -125,6 +126,12 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
125
126
  throw e;
126
127
  }
127
128
 
129
+ // Pre-syntax check: fail fast if file has obvious syntax errors
130
+ const syntaxCheck = preSyntaxCheck(original, relative);
131
+ if (!syntaxCheck.valid) {
132
+ throw new Error(`${syntaxCheck.error}. ${syntaxCheck.recoveryHint || ''}`);
133
+ }
134
+
128
135
  let working = original;
129
136
  const results = [];
130
137
  for (let i = 0; i < instructions.length; i++) {
@@ -138,6 +145,15 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
138
145
  return this.#buildResponse({ preview, results, original, updated: working });
139
146
  }
140
147
 
148
+ // Pre-syntax check on edited content before LSP validation
149
+ const editedSyntaxCheck = preSyntaxCheck(working, relative);
150
+ if (!editedSyntaxCheck.valid) {
151
+ throw new Error(
152
+ `Edit would introduce syntax error: ${editedSyntaxCheck.error}. ` +
153
+ 'Check your edit instructions for balanced braces/brackets.'
154
+ );
155
+ }
156
+
141
157
  const diagnostics = await this.#validateWithLsp(info, relative, working);
142
158
  const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
143
159
  if (hasErrors) {
@@ -1,6 +1,9 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
1
3
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
4
  import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
3
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
+ import { preSyntaxCheck } from './csharpSyntaxCheck.js';
4
7
 
5
8
  export class ScriptEditStructuredToolHandler extends BaseToolHandler {
6
9
  constructor(unityConnection) {
@@ -91,6 +94,22 @@ export class ScriptEditStructuredToolHandler extends BaseToolHandler {
91
94
 
92
95
  // Map operations to LSP extensions
93
96
  const info = await this.projectInfo.get();
97
+
98
+ // Pre-syntax check: fail fast if file has obvious syntax errors
99
+ const absolutePath = path.join(info.projectRoot, relative.replace(/\//g, path.sep));
100
+ try {
101
+ const content = await fs.readFile(absolutePath, 'utf8');
102
+ const syntaxCheck = preSyntaxCheck(content, relative);
103
+ if (!syntaxCheck.valid) {
104
+ throw new Error(`${syntaxCheck.error}. ${syntaxCheck.recoveryHint || ''}`);
105
+ }
106
+ } catch (e) {
107
+ // Re-throw syntax errors, let LSP handle file-not-found
108
+ if (e && e.code !== 'ENOENT') {
109
+ throw e;
110
+ }
111
+ }
112
+
94
113
  if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
95
114
 
96
115
  if (operation === 'replace_body') {
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Lightweight C# syntax pre-check to detect obvious errors before LSP processing.
3
+ * This helps fail fast instead of waiting for 60s LSP timeout on malformed files.
4
+ */
5
+
6
+ /**
7
+ * Check for balanced braces, brackets, and parentheses.
8
+ * @param {string} content - C# source code
9
+ * @returns {{ valid: boolean, error?: string, details?: object }}
10
+ */
11
+ export function checkBraceBalance(content) {
12
+ const stack = [];
13
+ const pairs = { '{': '}', '[': ']', '(': ')' };
14
+ const openings = new Set(Object.keys(pairs));
15
+ const closings = new Set(Object.values(pairs));
16
+
17
+ let inString = false;
18
+ let inChar = false;
19
+ let inSingleLineComment = false;
20
+ let inMultiLineComment = false;
21
+ let lineNumber = 1;
22
+ let columnNumber = 1;
23
+
24
+ for (let i = 0; i < content.length; i++) {
25
+ const ch = content[i];
26
+ const next = content[i + 1];
27
+ const prev = content[i - 1];
28
+
29
+ // Track line numbers
30
+ if (ch === '\n') {
31
+ lineNumber++;
32
+ columnNumber = 1;
33
+ inSingleLineComment = false;
34
+ continue;
35
+ }
36
+ columnNumber++;
37
+
38
+ // Skip comments
39
+ if (!inString && !inChar) {
40
+ if (inMultiLineComment) {
41
+ if (ch === '*' && next === '/') {
42
+ inMultiLineComment = false;
43
+ i++;
44
+ columnNumber++;
45
+ }
46
+ continue;
47
+ }
48
+ if (inSingleLineComment) {
49
+ continue;
50
+ }
51
+ if (ch === '/' && next === '/') {
52
+ inSingleLineComment = true;
53
+ continue;
54
+ }
55
+ if (ch === '/' && next === '*') {
56
+ inMultiLineComment = true;
57
+ i++;
58
+ columnNumber++;
59
+ continue;
60
+ }
61
+ }
62
+
63
+ // Handle strings (skip escaped quotes)
64
+ if (!inChar && !inSingleLineComment && !inMultiLineComment) {
65
+ if (ch === '"' && prev !== '\\') {
66
+ // Check for verbatim string @""
67
+ if (!inString && prev === '@') {
68
+ // Verbatim string - skip until unescaped closing "
69
+ i++;
70
+ while (i < content.length) {
71
+ if (content[i] === '"') {
72
+ if (content[i + 1] === '"') {
73
+ i++; // escaped quote in verbatim
74
+ } else {
75
+ break;
76
+ }
77
+ }
78
+ if (content[i] === '\n') {
79
+ lineNumber++;
80
+ columnNumber = 0;
81
+ }
82
+ i++;
83
+ columnNumber++;
84
+ }
85
+ continue;
86
+ }
87
+ inString = !inString;
88
+ continue;
89
+ }
90
+ }
91
+
92
+ // Handle char literals
93
+ if (!inString && !inSingleLineComment && !inMultiLineComment) {
94
+ if (ch === "'" && prev !== '\\') {
95
+ inChar = !inChar;
96
+ continue;
97
+ }
98
+ }
99
+
100
+ // Skip if inside string or char
101
+ if (inString || inChar) continue;
102
+
103
+ // Track braces
104
+ if (openings.has(ch)) {
105
+ stack.push({ char: ch, line: lineNumber, column: columnNumber });
106
+ } else if (closings.has(ch)) {
107
+ if (stack.length === 0) {
108
+ return {
109
+ valid: false,
110
+ error: `Unexpected closing '${ch}' at line ${lineNumber}, column ${columnNumber}`,
111
+ details: { line: lineNumber, column: columnNumber, char: ch }
112
+ };
113
+ }
114
+ const top = stack.pop();
115
+ if (pairs[top.char] !== ch) {
116
+ return {
117
+ valid: false,
118
+ error: `Mismatched '${top.char}' (line ${top.line}) and '${ch}' (line ${lineNumber})`,
119
+ details: {
120
+ opening: { char: top.char, line: top.line, column: top.column },
121
+ closing: { char: ch, line: lineNumber, column: columnNumber }
122
+ }
123
+ };
124
+ }
125
+ }
126
+ }
127
+
128
+ if (stack.length > 0) {
129
+ const unclosed = stack.map(s => `'${s.char}' at line ${s.line}`).join(', ');
130
+ return {
131
+ valid: false,
132
+ error: `Unclosed brackets: ${unclosed}`,
133
+ details: { unclosed: stack }
134
+ };
135
+ }
136
+
137
+ return { valid: true };
138
+ }
139
+
140
+ /**
141
+ * Perform lightweight pre-validation on C# source code.
142
+ * Returns early with actionable error if basic syntax is broken.
143
+ *
144
+ * @param {string} content - C# source code
145
+ * @param {string} [filePath] - Optional file path for error messages
146
+ * @returns {{ valid: boolean, error?: string, recoveryHint?: string }}
147
+ */
148
+ export function preSyntaxCheck(content, filePath = 'file') {
149
+ // Check 1: Brace balance
150
+ const braceResult = checkBraceBalance(content);
151
+ if (!braceResult.valid) {
152
+ return {
153
+ valid: false,
154
+ error: `Syntax error in ${filePath}: ${braceResult.error}`,
155
+ recoveryHint:
156
+ 'The file has unbalanced braces/brackets. ' +
157
+ 'Use Bash tool with "cat > file << EOF" to rewrite the file, ' +
158
+ 'or fix manually in Unity Editor.'
159
+ };
160
+ }
161
+
162
+ // Check 2: Basic structure validation (namespace/class/method patterns)
163
+ // Lightweight check - just ensure there's at least one class/struct/interface
164
+ const hasTypeDeclaration = /\b(class|struct|interface|enum|record)\s+\w+/.test(content);
165
+ if (!hasTypeDeclaration && content.trim().length > 100) {
166
+ // File has content but no type declaration - likely corrupted
167
+ return {
168
+ valid: false,
169
+ error: `No type declaration found in ${filePath}`,
170
+ recoveryHint:
171
+ 'The file appears to be missing class/struct/interface declarations. ' +
172
+ 'This may indicate file corruption. Check the file content.'
173
+ };
174
+ }
175
+
176
+ return { valid: true };
177
+ }
@@ -166,10 +166,17 @@ export class LspRpcClient {
166
166
  );
167
167
  return await this.#requestWithRetry(method, params, attempt + 1);
168
168
  }
169
- // Standardize error message
170
- const hint = recoverable
171
- ? 'The server was restarted. Try again if the issue persists.'
172
- : 'Check request parameters or increase lsp.requestTimeoutMs.';
169
+ // Standardize error message with actionable recovery instructions
170
+ let hint;
171
+ if (recoverable) {
172
+ hint =
173
+ 'LSP failed to parse the file (likely syntax errors). ' +
174
+ 'Recovery options: (1) Use Bash tool with "cat > file << EOF" to rewrite the file, ' +
175
+ '(2) Fix syntax errors manually in Unity Editor, ' +
176
+ '(3) Check for unbalanced braces/brackets in the target file.';
177
+ } else {
178
+ hint = 'Check request parameters or increase lsp.requestTimeoutMs.';
179
+ }
173
180
  throw new Error(`[${method}] failed: ${msg}. ${hint}`);
174
181
  }
175
182
  }