@akiojin/unity-mcp-server 2.37.2 → 2.38.1
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 +1 -1
- package/src/core/config.js +27 -1
- package/src/handlers/console/ConsoleReadToolHandler.js +18 -16
- package/src/handlers/editor/EditorQuitToolHandler.js +26 -0
- package/src/handlers/index.js +2 -0
- package/src/handlers/test/TestGetStatusToolHandler.js +34 -9
- package/src/handlers/test/TestRunToolHandler.js +6 -1
- package/src/lsp/CSharpLspUtils.js +48 -9
- package/src/utils/testRunState.js +90 -0
package/package.json
CHANGED
package/src/core/config.js
CHANGED
|
@@ -17,6 +17,32 @@ function merge(a, b) {
|
|
|
17
17
|
return out;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function resolvePackageVersion() {
|
|
21
|
+
const candidates = [];
|
|
22
|
+
|
|
23
|
+
// Resolve relative to this module (always inside mcp-server/src/core)
|
|
24
|
+
try {
|
|
25
|
+
const moduleDir = path.dirname(new URL(import.meta.url).pathname);
|
|
26
|
+
candidates.push(path.resolve(moduleDir, '../../package.json'));
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
// When executed from workspace root (monorepo) or inside mcp-server package
|
|
30
|
+
try {
|
|
31
|
+
const here = findUpSync('package.json', { cwd: process.cwd() });
|
|
32
|
+
if (here) candidates.push(here);
|
|
33
|
+
} catch {}
|
|
34
|
+
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
if (!candidate || !fs.existsSync(candidate)) continue;
|
|
38
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
39
|
+
if (pkg?.version) return pkg.version;
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return '0.1.0';
|
|
44
|
+
}
|
|
45
|
+
|
|
20
46
|
/**
|
|
21
47
|
* Base configuration for Unity MCP Server Server
|
|
22
48
|
*/
|
|
@@ -44,7 +70,7 @@ const baseConfig = {
|
|
|
44
70
|
// Server settings
|
|
45
71
|
server: {
|
|
46
72
|
name: 'unity-mcp-server',
|
|
47
|
-
version:
|
|
73
|
+
version: resolvePackageVersion(),
|
|
48
74
|
description: 'MCP server for Unity Editor integration'
|
|
49
75
|
},
|
|
50
76
|
|
|
@@ -11,6 +11,11 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
|
|
|
11
11
|
{
|
|
12
12
|
type: 'object',
|
|
13
13
|
properties: {
|
|
14
|
+
raw: {
|
|
15
|
+
type: 'boolean',
|
|
16
|
+
description:
|
|
17
|
+
'When true, do not expand/validate logTypes and pass them directly to Unity. Use with caution.'
|
|
18
|
+
},
|
|
14
19
|
count: {
|
|
15
20
|
type: 'number',
|
|
16
21
|
description: 'Number of logs to retrieve (1-1000, default: 100)',
|
|
@@ -19,10 +24,11 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
|
|
|
19
24
|
},
|
|
20
25
|
logTypes: {
|
|
21
26
|
type: 'array',
|
|
22
|
-
description:
|
|
27
|
+
description:
|
|
28
|
+
'Filter by log types. Allowed: Log, Warning, Error. Error expands to include Exception/Assert internally. Default: all three.',
|
|
23
29
|
items: {
|
|
24
30
|
type: 'string',
|
|
25
|
-
enum: ['
|
|
31
|
+
enum: ['Log', 'Warning', 'Error']
|
|
26
32
|
}
|
|
27
33
|
},
|
|
28
34
|
filterText: {
|
|
@@ -88,11 +94,10 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
|
|
|
88
94
|
throw new Error('logTypes must be an array');
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
const validTypes = ['
|
|
97
|
+
const validTypes = ['Log', 'Warning', 'Error'];
|
|
92
98
|
for (const type of logTypes) {
|
|
93
99
|
if (!validTypes.includes(type)) {
|
|
94
|
-
|
|
95
|
-
console.warn(`Invalid log type: ${type}. Will be treated as 'All'.`);
|
|
100
|
+
throw new Error(`logTypes must be one of: ${validTypes.join(', ')}`);
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
}
|
|
@@ -177,34 +182,31 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
|
|
|
177
182
|
groupBy
|
|
178
183
|
} = params;
|
|
179
184
|
|
|
180
|
-
//
|
|
185
|
+
// Allowed: Log / Warning / Error. Error expands to include Exception/Assert.
|
|
181
186
|
let expandedLogTypes = [];
|
|
182
|
-
|
|
183
|
-
if (!logTypes || logTypes.length === 0 || logTypes.includes('All')) {
|
|
184
|
-
// Default to all types
|
|
187
|
+
if (!logTypes || logTypes.length === 0) {
|
|
185
188
|
expandedLogTypes = ['Log', 'Warning', 'Error', 'Exception', 'Assert'];
|
|
186
189
|
} else {
|
|
187
|
-
// Expand each simplified type to Unity types
|
|
188
190
|
logTypes.forEach(type => {
|
|
189
191
|
switch (type) {
|
|
190
|
-
case '
|
|
192
|
+
case 'Log':
|
|
191
193
|
expandedLogTypes.push('Log');
|
|
192
194
|
break;
|
|
193
195
|
case 'Warning':
|
|
194
196
|
expandedLogTypes.push('Warning');
|
|
195
197
|
break;
|
|
196
198
|
case 'Error':
|
|
197
|
-
// Error includes all error-related types
|
|
198
199
|
expandedLogTypes.push('Error', 'Exception', 'Assert');
|
|
199
200
|
break;
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
default:
|
|
202
|
+
// ignore (validate already enforces)
|
|
202
203
|
break;
|
|
203
204
|
}
|
|
204
205
|
});
|
|
205
|
-
|
|
206
|
-
// Remove duplicates
|
|
207
206
|
expandedLogTypes = [...new Set(expandedLogTypes)];
|
|
207
|
+
if (expandedLogTypes.length === 0) {
|
|
208
|
+
expandedLogTypes = ['Log', 'Warning', 'Error', 'Exception', 'Assert'];
|
|
209
|
+
}
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
// Ensure connection to Unity
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handler to quit Unity Editor safely.
|
|
5
|
+
*/
|
|
6
|
+
export class EditorQuitToolHandler extends BaseToolHandler {
|
|
7
|
+
constructor(unityConnection) {
|
|
8
|
+
super('editor_quit', 'Quit Unity Editor', {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
this.unityConnection = unityConnection;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async execute() {
|
|
17
|
+
if (!this.unityConnection.isConnected()) {
|
|
18
|
+
await this.unityConnection.connect();
|
|
19
|
+
}
|
|
20
|
+
const response = await this.unityConnection.sendCommand('quit_editor', {});
|
|
21
|
+
if (response.error) {
|
|
22
|
+
throw new Error(response.error);
|
|
23
|
+
}
|
|
24
|
+
return response;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/handlers/index.js
CHANGED
|
@@ -94,6 +94,7 @@ import { EditorLayersManageToolHandler } from './editor/EditorLayersManageToolHa
|
|
|
94
94
|
import { EditorSelectionManageToolHandler } from './editor/EditorSelectionManageToolHandler.js';
|
|
95
95
|
import { EditorWindowsManageToolHandler } from './editor/EditorWindowsManageToolHandler.js';
|
|
96
96
|
import { EditorToolsManageToolHandler } from './editor/EditorToolsManageToolHandler.js';
|
|
97
|
+
import { EditorQuitToolHandler } from './editor/EditorQuitToolHandler.js';
|
|
97
98
|
import { SettingsGetToolHandler } from './settings/SettingsGetToolHandler.js';
|
|
98
99
|
import { SettingsUpdateToolHandler } from './settings/SettingsUpdateToolHandler.js';
|
|
99
100
|
import PackageManagerToolHandler from './package/PackageManagerToolHandler.js';
|
|
@@ -392,6 +393,7 @@ const HANDLER_CLASSES = [
|
|
|
392
393
|
EditorSelectionManageToolHandler,
|
|
393
394
|
EditorWindowsManageToolHandler,
|
|
394
395
|
EditorToolsManageToolHandler,
|
|
396
|
+
EditorQuitToolHandler,
|
|
395
397
|
|
|
396
398
|
// Settings handlers
|
|
397
399
|
SettingsGetToolHandler,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
2
2
|
import * as testResultsCache from '../../utils/testResultsCache.js';
|
|
3
|
+
import * as testRunState from '../../utils/testRunState.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Handler for getting Unity test execution status
|
|
@@ -33,13 +34,32 @@ export class TestGetStatusToolHandler extends BaseToolHandler {
|
|
|
33
34
|
* @returns {Promise<Object>} Test execution status and results
|
|
34
35
|
*/
|
|
35
36
|
async execute(params) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
// Ensure connection to Unity
|
|
40
|
+
if (!this.unityConnection.isConnected()) {
|
|
41
|
+
await this.unityConnection.connect();
|
|
42
|
+
}
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
// Send command to Unity
|
|
45
|
+
response = await this.unityConnection.sendCommand('get_test_status', params || {});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// 一度リトライしてみる(ドメインリロード直後を考慮)
|
|
48
|
+
try {
|
|
49
|
+
await this.unityConnection.connect();
|
|
50
|
+
response = await this.unityConnection.sendCommand('get_test_status', params || {});
|
|
51
|
+
} catch (retryError) {
|
|
52
|
+
// Connection or transport error persists: fall back to cached run state so callers
|
|
53
|
+
// can still distinguish "テスト起因のPlayMode" かどうか。
|
|
54
|
+
const cached = testRunState.getState();
|
|
55
|
+
return {
|
|
56
|
+
...cached,
|
|
57
|
+
status: cached.status || 'error',
|
|
58
|
+
error: retryError.message,
|
|
59
|
+
connectionState: 'disconnected'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
43
63
|
|
|
44
64
|
// Handle Unity response
|
|
45
65
|
if (response.error) {
|
|
@@ -48,12 +68,14 @@ export class TestGetStatusToolHandler extends BaseToolHandler {
|
|
|
48
68
|
|
|
49
69
|
// Return status directly if still running or idle
|
|
50
70
|
if (response.status === 'running') {
|
|
51
|
-
|
|
71
|
+
const merged = { ...testRunState.updateFromStatus(response), ...response };
|
|
72
|
+
return merged;
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
if (response.status === 'idle') {
|
|
76
|
+
testRunState.markIdle();
|
|
55
77
|
const cached = await this.testResultsCache.loadCachedTestResults();
|
|
56
|
-
return cached || response;
|
|
78
|
+
return cached || testRunState.getState() || response;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
// Format completed results
|
|
@@ -68,13 +90,16 @@ export class TestGetStatusToolHandler extends BaseToolHandler {
|
|
|
68
90
|
inconclusiveTests: response.inconclusiveTests,
|
|
69
91
|
summary: `${response.passedTests}/${response.totalTests} tests passed`,
|
|
70
92
|
failures: response.failures || [],
|
|
71
|
-
tests: response.tests || []
|
|
93
|
+
tests: response.tests || [],
|
|
94
|
+
testMode: response.testMode || testRunState.getState().testMode,
|
|
95
|
+
runId: testRunState.getState().runId
|
|
72
96
|
};
|
|
73
97
|
|
|
74
98
|
if (response.latestResult) {
|
|
75
99
|
result.latestResult = response.latestResult;
|
|
76
100
|
}
|
|
77
101
|
|
|
102
|
+
testRunState.updateFromStatus({ ...result, status: 'completed' });
|
|
78
103
|
const artifactPath = await this.testResultsCache.persistTestResults(result);
|
|
79
104
|
const cachedResult = await this.testResultsCache.loadCachedTestResults(artifactPath);
|
|
80
105
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
2
2
|
import { resetTestResultsCache } from '../../utils/testResultsCache.js';
|
|
3
|
+
import * as testRunState from '../../utils/testRunState.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Handler for running Unity NUnit tests via Test Runner API
|
|
@@ -67,10 +68,14 @@ export class TestRunToolHandler extends BaseToolHandler {
|
|
|
67
68
|
|
|
68
69
|
// Handle new non-blocking response format (status: "running")
|
|
69
70
|
if (response.status === 'running') {
|
|
71
|
+
const runState = testRunState.startRun(response.testMode || params.testMode, response.runId);
|
|
70
72
|
return {
|
|
71
73
|
status: 'running',
|
|
72
74
|
message:
|
|
73
|
-
response.message || 'Test execution started. Use get_test_status to check progress.'
|
|
75
|
+
response.message || 'Test execution started. Use get_test_status to check progress.',
|
|
76
|
+
testMode: runState.testMode || response.testMode,
|
|
77
|
+
runId: runState.runId || response.runId,
|
|
78
|
+
startedAt: runState.startedAt
|
|
74
79
|
};
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -134,17 +134,38 @@ export class CSharpLspUtils {
|
|
|
134
134
|
if (!desired) throw new Error('mcp-server version not found; cannot resolve LSP tag');
|
|
135
135
|
const current = this.readLocalVersion(rid);
|
|
136
136
|
if (fs.existsSync(p) && current === desired) return p;
|
|
137
|
-
await this.autoDownload(rid, desired);
|
|
137
|
+
const resolved = await this.autoDownload(rid, desired);
|
|
138
138
|
if (!fs.existsSync(p)) throw new Error('csharp-lsp binary not found after download');
|
|
139
|
-
this.writeLocalVersion(rid, desired);
|
|
139
|
+
this.writeLocalVersion(rid, resolved || desired);
|
|
140
140
|
return p;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
async autoDownload(rid, version) {
|
|
144
144
|
const repo = process.env.GITHUB_REPOSITORY || 'akiojin/unity-mcp-server';
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
145
|
+
|
|
146
|
+
const fetchManifest = async ver => {
|
|
147
|
+
const tag = `v${ver}`;
|
|
148
|
+
const manifestUrl = `https://github.com/${repo}/releases/download/${tag}/csharp-lsp-manifest.json`;
|
|
149
|
+
const manifest = await this.fetchJson(manifestUrl);
|
|
150
|
+
return { manifest, tag };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
let targetVersion = version;
|
|
154
|
+
let manifest;
|
|
155
|
+
try {
|
|
156
|
+
({ manifest } = await fetchManifest(targetVersion));
|
|
157
|
+
} catch (e) {
|
|
158
|
+
// Gracefully fall back to the latest release when the requested manifest is missing (404).
|
|
159
|
+
if (String(e?.message || '').includes('HTTP 404')) {
|
|
160
|
+
const latest = await this.fetchLatestReleaseVersion(repo);
|
|
161
|
+
if (!latest) throw e;
|
|
162
|
+
targetVersion = latest;
|
|
163
|
+
({ manifest } = await fetchManifest(targetVersion));
|
|
164
|
+
} else {
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
148
169
|
const entry = manifest?.assets?.[rid];
|
|
149
170
|
if (!entry?.url || !entry?.sha256) throw new Error(`manifest missing entry for ${rid}`);
|
|
150
171
|
|
|
@@ -154,17 +175,35 @@ export class CSharpLspUtils {
|
|
|
154
175
|
await this.downloadTo(entry.url, tmp);
|
|
155
176
|
const actual = await this.sha256File(tmp);
|
|
156
177
|
if (String(actual).toLowerCase() !== String(entry.sha256).toLowerCase()) {
|
|
157
|
-
try {
|
|
178
|
+
try {
|
|
179
|
+
fs.unlinkSync(tmp);
|
|
180
|
+
} catch {}
|
|
158
181
|
throw new Error('checksum mismatch for csharp-lsp asset');
|
|
159
182
|
}
|
|
160
183
|
// atomic replace
|
|
161
|
-
try {
|
|
184
|
+
try {
|
|
185
|
+
fs.renameSync(tmp, dest);
|
|
186
|
+
} catch (e) {
|
|
162
187
|
// Windows may need removal before rename
|
|
163
|
-
try {
|
|
188
|
+
try {
|
|
189
|
+
fs.unlinkSync(dest);
|
|
190
|
+
} catch {}
|
|
164
191
|
fs.renameSync(tmp, dest);
|
|
165
192
|
}
|
|
166
|
-
try {
|
|
193
|
+
try {
|
|
194
|
+
if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
|
|
195
|
+
} catch {}
|
|
167
196
|
logger.info(`[csharp-lsp] downloaded: ${path.basename(dest)} @ ${path.dirname(dest)}`);
|
|
197
|
+
return targetVersion;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async fetchLatestReleaseVersion(repo) {
|
|
201
|
+
const url = `https://api.github.com/repos/${repo}/releases/latest`;
|
|
202
|
+
const json = await this.fetchJson(url);
|
|
203
|
+
const tag = json?.tag_name || '';
|
|
204
|
+
const version = tag.replace(/^v/, '');
|
|
205
|
+
if (!version) throw new Error('latest release version not found');
|
|
206
|
+
return version;
|
|
168
207
|
}
|
|
169
208
|
|
|
170
209
|
async fetchJson(url) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { WORKSPACE_ROOT, logger } from '../core/config.js';
|
|
5
|
+
|
|
6
|
+
// Simple in-memory state for the latest test run.
|
|
7
|
+
// Persisting is unnecessary for the current use case because the Node process
|
|
8
|
+
// stays alive across Unity domain reloads, which is where we lose connection.
|
|
9
|
+
const state = {
|
|
10
|
+
status: 'idle', // idle | running | completed | error
|
|
11
|
+
runId: null,
|
|
12
|
+
testMode: null,
|
|
13
|
+
startedAt: null,
|
|
14
|
+
completedAt: null,
|
|
15
|
+
lastUpdate: null
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const workspaceUnityRoot = path.join(WORKSPACE_ROOT || process.cwd(), '.unity');
|
|
19
|
+
const runStatePath = path.join(workspaceUnityRoot, 'tests', 'test-run-state.json');
|
|
20
|
+
|
|
21
|
+
// Load persisted state once at module load
|
|
22
|
+
await (async () => {
|
|
23
|
+
try {
|
|
24
|
+
const data = await fs.readFile(runStatePath, 'utf8');
|
|
25
|
+
const parsed = JSON.parse(data);
|
|
26
|
+
Object.assign(state, parsed);
|
|
27
|
+
logger.info(`[testRunState] Loaded persisted state from ${runStatePath}`);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code !== 'ENOENT') {
|
|
30
|
+
logger.warn(`[testRunState] Failed to load state from ${runStatePath}: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
export function startRun(testMode, runId) {
|
|
36
|
+
const now = new Date().toISOString();
|
|
37
|
+
state.status = 'running';
|
|
38
|
+
state.runId = runId || crypto.randomUUID();
|
|
39
|
+
state.testMode = testMode || state.testMode;
|
|
40
|
+
state.startedAt = now;
|
|
41
|
+
state.completedAt = null;
|
|
42
|
+
state.lastUpdate = now;
|
|
43
|
+
persist();
|
|
44
|
+
return { ...state };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function updateFromStatus(response) {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
if (response?.status) {
|
|
50
|
+
state.status = response.status;
|
|
51
|
+
}
|
|
52
|
+
if (response?.testMode) {
|
|
53
|
+
state.testMode = response.testMode;
|
|
54
|
+
}
|
|
55
|
+
if (response?.runId) {
|
|
56
|
+
state.runId = response.runId;
|
|
57
|
+
}
|
|
58
|
+
state.lastUpdate = now;
|
|
59
|
+
|
|
60
|
+
if (response?.status === 'completed') {
|
|
61
|
+
state.completedAt = now;
|
|
62
|
+
}
|
|
63
|
+
if (response?.status === 'idle') {
|
|
64
|
+
state.runId = null;
|
|
65
|
+
state.testMode = null;
|
|
66
|
+
state.startedAt = null;
|
|
67
|
+
state.completedAt = null;
|
|
68
|
+
}
|
|
69
|
+
persist();
|
|
70
|
+
return { ...state };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function markIdle() {
|
|
74
|
+
const res = updateFromStatus({ status: 'idle' });
|
|
75
|
+
return res;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getState() {
|
|
79
|
+
return { ...state };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function persist() {
|
|
83
|
+
try {
|
|
84
|
+
const dir = path.dirname(runStatePath);
|
|
85
|
+
await fs.mkdir(dir, { recursive: true });
|
|
86
|
+
await fs.writeFile(runStatePath, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.warn(`[testRunState] Failed to persist state to ${runStatePath}: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|