ultimate-unreal-engine-mcp 0.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 +729 -0
- package/dist/build/error-parser.js +51 -0
- package/dist/build/fix-suggester.js +84 -0
- package/dist/build/ubt-runner.js +146 -0
- package/dist/cli.js +13 -0
- package/dist/config.js +8 -0
- package/dist/docs/data/ue57-api.js +228 -0
- package/dist/docs/doc-index.js +110 -0
- package/dist/docs/types.js +4 -0
- package/dist/generators/class-generator.js +363 -0
- package/dist/generators/file-modifier.js +276 -0
- package/dist/generators/uht-validator.js +177 -0
- package/dist/index.js +89 -0
- package/dist/parsers/cpp-class-index.js +230 -0
- package/dist/parsers/cpp-parser.js +369 -0
- package/dist/parsers/ini-parser.js +216 -0
- package/dist/parsers/uproject-parser.js +130 -0
- package/dist/plugin-bridge/client.js +217 -0
- package/dist/plugin-bridge/protocol.js +6 -0
- package/dist/plugin-bridge/retry.js +23 -0
- package/dist/setup.js +209 -0
- package/dist/tools/ai-systems/index.js +247 -0
- package/dist/tools/ai-systems/types.js +4 -0
- package/dist/tools/animation/index.js +241 -0
- package/dist/tools/animation/types.js +4 -0
- package/dist/tools/audio/index.js +204 -0
- package/dist/tools/audio/types.js +4 -0
- package/dist/tools/blueprint/index.js +495 -0
- package/dist/tools/blueprint/types.js +4 -0
- package/dist/tools/build/index.js +163 -0
- package/dist/tools/chaos/index.js +230 -0
- package/dist/tools/chaos/types.js +4 -0
- package/dist/tools/collision-physics/index.js +211 -0
- package/dist/tools/config/index.js +288 -0
- package/dist/tools/cpp/index.js +305 -0
- package/dist/tools/docs/index.js +251 -0
- package/dist/tools/editor/index.js +242 -0
- package/dist/tools/gas/index.js +222 -0
- package/dist/tools/gas/types.js +5 -0
- package/dist/tools/import-export/index.js +218 -0
- package/dist/tools/input/index.js +146 -0
- package/dist/tools/known-issues/index.js +88 -0
- package/dist/tools/known-issues/middleware.js +55 -0
- package/dist/tools/known-issues/store.js +125 -0
- package/dist/tools/livelink/index.js +203 -0
- package/dist/tools/livelink/types.js +4 -0
- package/dist/tools/material/index.js +190 -0
- package/dist/tools/motion-design/index.js +251 -0
- package/dist/tools/motion-design/types.js +6 -0
- package/dist/tools/movie-render/index.js +220 -0
- package/dist/tools/networking/index.js +149 -0
- package/dist/tools/pcg/index.js +164 -0
- package/dist/tools/selection/index.js +180 -0
- package/dist/tools/sequencer/index.js +218 -0
- package/dist/tools/validation/index.js +183 -0
- package/dist/tools/validation/types.js +4 -0
- package/dist/tools/viewport/index.js +310 -0
- package/dist/tools/worldpartition/index.js +226 -0
- package/dist/tools/worldpartition/types.js +4 -0
- package/dist/utils/execFileNoThrow.js +40 -0
- package/dist/utils/logger.js +27 -0
- package/dist/utils/path-guard.js +26 -0
- package/package.json +40 -0
- package/unreal-plugin/MCPBridge/MCPBridge.uplugin +29 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/MCPBridgeEditor.Build.cs +68 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAICommands.cpp +919 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAICommands.h +23 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPActorCommands.cpp +415 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPActorCommands.h +16 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAnimationCommands.cpp +653 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAnimationCommands.h +24 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAssetCommands.cpp +290 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAssetCommands.h +17 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAudioCommands.cpp +624 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAudioCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintHandlers.cpp +616 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintHandlers.h +25 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintWriteHandlers.cpp +744 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintWriteHandlers.h +24 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBridgeEditor.cpp +23 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBridgeSubsystem.cpp +149 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBridgeSubsystem.h +38 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPChaosCommands.cpp +771 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPChaosCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPCollisionPhysicsCommands.cpp +749 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPCollisionPhysicsCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPEditorStateCommands.cpp +172 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPEditorStateCommands.h +16 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPGASCommands.cpp +715 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPGASCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPImportExportCommands.cpp +679 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPImportExportCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPInputHandlers.cpp +381 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPInputHandlers.h +24 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPLiveLinkCommands.cpp +504 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPLiveLinkCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMaterialCommands.cpp +511 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMaterialCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMotionDesignCommands.cpp +1110 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMotionDesignCommands.h +28 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMovieRenderCommands.cpp +590 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMovieRenderCommands.h +16 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPNetworkingCommands.cpp +482 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPNetworkingCommands.h +16 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPPieCommands.cpp +338 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPPieCommands.h +16 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSelectionCommands.cpp +677 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSelectionCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSequencerCommands.cpp +721 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSequencerCommands.h +16 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPValidationCommands.cpp +368 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPValidationCommands.h +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPViewportCommands.cpp +1208 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPViewportCommands.h +29 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPWorldPartitionCommands.cpp +822 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPWorldPartitionCommands.h +23 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Public/MCPBridgeEditor.h +14 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/MCPBridgeRuntime.Build.cs +28 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Private/MCPBridgeRuntime.cpp +22 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Private/MCPCommandRouter.cpp +118 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Private/MCPTcpServer.cpp +196 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Public/MCPBridgeRuntime.h +15 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Public/MCPCommandRouter.h +55 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Public/MCPTcpServer.h +59 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// UE-aware INI parser — standalone pure string-processing module.
|
|
2
|
+
// No fs imports — all I/O is the caller's responsibility.
|
|
3
|
+
// No console.log — pure transform, no side effects.
|
|
4
|
+
//
|
|
5
|
+
// Handles all five UE INI operators:
|
|
6
|
+
// plain = scalar assignment; last occurrence wins
|
|
7
|
+
// + append if not already present (no duplicates)
|
|
8
|
+
// . append always (duplicates allowed)
|
|
9
|
+
// - remove exact match
|
|
10
|
+
// ! clear all values for key (value after = is ignored)
|
|
11
|
+
//
|
|
12
|
+
// Values are always stored as string[] (scalars as single-element arrays).
|
|
13
|
+
// Section names and key names are case-sensitive per UE requirements.
|
|
14
|
+
// Indexed-array syntax (MyArray[0]=val) is stored with the bracket index
|
|
15
|
+
// as part of the literal key name — no special flattening.
|
|
16
|
+
// Struct/compound values (parens) are treated as opaque strings.
|
|
17
|
+
// Only lines where ; is the first non-whitespace char are treated as comments;
|
|
18
|
+
// mid-line semicolons are preserved verbatim in the value.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Internal helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const OPERATORS = new Set(['+', '-', '.', '!']);
|
|
23
|
+
/**
|
|
24
|
+
* Strips the UE operator prefix (+ - . !) from a raw key token and
|
|
25
|
+
* returns { operator, key }.
|
|
26
|
+
* If the first character is not an operator, operator is '' (plain =).
|
|
27
|
+
*/
|
|
28
|
+
function splitOperator(rawKey) {
|
|
29
|
+
const first = rawKey[0] ?? '';
|
|
30
|
+
if (OPERATORS.has(first)) {
|
|
31
|
+
return { operator: first, key: rawKey.slice(1) };
|
|
32
|
+
}
|
|
33
|
+
return { operator: '', key: rawKey };
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// parseIni
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/**
|
|
39
|
+
* Parse a full INI file content string into a structured map.
|
|
40
|
+
* Handles all five UE operators: plain =, +, -, ., !
|
|
41
|
+
* Section names are case-sensitive (UE requirement).
|
|
42
|
+
*/
|
|
43
|
+
export function parseIni(content) {
|
|
44
|
+
const result = {};
|
|
45
|
+
let currentSection = '';
|
|
46
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
47
|
+
const line = rawLine.trim();
|
|
48
|
+
// Skip blank lines
|
|
49
|
+
if (!line)
|
|
50
|
+
continue;
|
|
51
|
+
// Skip comment lines (first non-whitespace char is ;)
|
|
52
|
+
if (line.startsWith(';'))
|
|
53
|
+
continue;
|
|
54
|
+
// Section header: [SectionName]
|
|
55
|
+
if (line.startsWith('[') && line.endsWith(']')) {
|
|
56
|
+
currentSection = line.slice(1, -1);
|
|
57
|
+
if (!result[currentSection]) {
|
|
58
|
+
result[currentSection] = {};
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Key=Value line — must have an = sign
|
|
63
|
+
const eqIdx = line.indexOf('=');
|
|
64
|
+
if (eqIdx === -1)
|
|
65
|
+
continue; // malformed line — skip silently
|
|
66
|
+
const rawKey = line.slice(0, eqIdx).trim();
|
|
67
|
+
// Preserve value verbatim after the = (no trimming — spaces and semicolons kept)
|
|
68
|
+
const value = line.slice(eqIdx + 1);
|
|
69
|
+
const { operator, key } = splitOperator(rawKey);
|
|
70
|
+
// Ensure section exists (handles key lines before any section header)
|
|
71
|
+
if (!result[currentSection]) {
|
|
72
|
+
result[currentSection] = {};
|
|
73
|
+
}
|
|
74
|
+
const section = result[currentSection];
|
|
75
|
+
switch (operator) {
|
|
76
|
+
case '!':
|
|
77
|
+
// Clear all accumulated values for this key
|
|
78
|
+
section[key] = [];
|
|
79
|
+
break;
|
|
80
|
+
case '-':
|
|
81
|
+
// Remove exact match from array
|
|
82
|
+
section[key] = (section[key] ?? []).filter((v) => v !== value);
|
|
83
|
+
break;
|
|
84
|
+
case '+':
|
|
85
|
+
// Append only if value not already present
|
|
86
|
+
if (!(section[key] ?? []).includes(value)) {
|
|
87
|
+
section[key] = [...(section[key] ?? []), value];
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
case '.':
|
|
91
|
+
// Append unconditionally (duplicates allowed)
|
|
92
|
+
section[key] = [...(section[key] ?? []), value];
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
// Plain = : last occurrence wins — store as single-element array
|
|
96
|
+
section[key] = [value];
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// setIniValue
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
/**
|
|
106
|
+
* Write/update a single key in a section (plain = operator).
|
|
107
|
+
* Returns the new file content string — does not write to disk.
|
|
108
|
+
*
|
|
109
|
+
* Behaviour:
|
|
110
|
+
* - If the key exists in the target section (with any operator prefix),
|
|
111
|
+
* replaces the FIRST matching line with "key=value" and drops all
|
|
112
|
+
* subsequent lines for that key.
|
|
113
|
+
* - If the key is not found but the section exists, appends "key=value"
|
|
114
|
+
* at the end of the section block (before the next [Section] header).
|
|
115
|
+
* - If the section does not exist at all, appends an empty line, the
|
|
116
|
+
* section header, and "key=value" at the end of the file.
|
|
117
|
+
*
|
|
118
|
+
* Output uses \n line endings regardless of input style.
|
|
119
|
+
*/
|
|
120
|
+
export function setIniValue(content, section, key, value) {
|
|
121
|
+
const lines = content.split(/\r?\n/);
|
|
122
|
+
let inTargetSection = false;
|
|
123
|
+
let sectionFound = false;
|
|
124
|
+
let keyReplaced = false;
|
|
125
|
+
const output = [];
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
// Detect section headers
|
|
129
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
130
|
+
// Leaving a section — if we were in target section and haven't placed
|
|
131
|
+
// the key yet, inject it before moving to the new section
|
|
132
|
+
if (inTargetSection && !keyReplaced) {
|
|
133
|
+
output.push(`${key}=${value}`);
|
|
134
|
+
keyReplaced = true;
|
|
135
|
+
}
|
|
136
|
+
inTargetSection = trimmed.slice(1, -1) === section;
|
|
137
|
+
if (inTargetSection)
|
|
138
|
+
sectionFound = true;
|
|
139
|
+
output.push(line);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Within target section: look for lines whose bare key matches
|
|
143
|
+
if (inTargetSection && !keyReplaced) {
|
|
144
|
+
const eqIdx = trimmed.indexOf('=');
|
|
145
|
+
if (eqIdx !== -1) {
|
|
146
|
+
const rawKey = trimmed.slice(0, eqIdx).trim();
|
|
147
|
+
const { key: bareKey } = splitOperator(rawKey);
|
|
148
|
+
if (bareKey === key) {
|
|
149
|
+
// Replace this line with the new key=value
|
|
150
|
+
output.push(`${key}=${value}`);
|
|
151
|
+
keyReplaced = true;
|
|
152
|
+
continue; // drop the original line
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (inTargetSection && keyReplaced) {
|
|
157
|
+
// Key already replaced — drop any further lines for the same key
|
|
158
|
+
const eqIdx = trimmed.indexOf('=');
|
|
159
|
+
if (eqIdx !== -1) {
|
|
160
|
+
const rawKey = trimmed.slice(0, eqIdx).trim();
|
|
161
|
+
const { key: bareKey } = splitOperator(rawKey);
|
|
162
|
+
if (bareKey === key) {
|
|
163
|
+
continue; // skip duplicate key lines
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
output.push(line);
|
|
168
|
+
}
|
|
169
|
+
// After loop: section was found but key never appeared — append at end
|
|
170
|
+
if (inTargetSection && !keyReplaced) {
|
|
171
|
+
output.push(`${key}=${value}`);
|
|
172
|
+
}
|
|
173
|
+
// Section was not in the file at all — append section + key at end
|
|
174
|
+
if (!sectionFound) {
|
|
175
|
+
output.push('', `[${section}]`, `${key}=${value}`);
|
|
176
|
+
}
|
|
177
|
+
return output.join('\n');
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// writeIniRemoveKey
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* Remove all lines for a key from a specified section (all operator prefixes).
|
|
184
|
+
* Returns the new file content string — does not write to disk.
|
|
185
|
+
*
|
|
186
|
+
* - Removes lines whose bare key (operator prefix stripped) matches target key.
|
|
187
|
+
* - No-op if section or key does not exist (returns content unchanged).
|
|
188
|
+
* - Output uses \n line endings regardless of input style.
|
|
189
|
+
*/
|
|
190
|
+
export function writeIniRemoveKey(content, section, key) {
|
|
191
|
+
const lines = content.split(/\r?\n/);
|
|
192
|
+
let inTargetSection = false;
|
|
193
|
+
const output = [];
|
|
194
|
+
for (const line of lines) {
|
|
195
|
+
const trimmed = line.trim();
|
|
196
|
+
// Detect section headers
|
|
197
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
198
|
+
inTargetSection = trimmed.slice(1, -1) === section;
|
|
199
|
+
output.push(line);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// Within target section: skip lines whose bare key matches
|
|
203
|
+
if (inTargetSection) {
|
|
204
|
+
const eqIdx = trimmed.indexOf('=');
|
|
205
|
+
if (eqIdx !== -1) {
|
|
206
|
+
const rawKey = trimmed.slice(0, eqIdx).trim();
|
|
207
|
+
const { key: bareKey } = splitOperator(rawKey);
|
|
208
|
+
if (bareKey === key) {
|
|
209
|
+
continue; // remove this line
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
output.push(line);
|
|
214
|
+
}
|
|
215
|
+
return output.join('\n');
|
|
216
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/parsers/uproject-parser.ts
|
|
2
|
+
// Zod-validated parsers for .uproject and .uplugin JSON descriptor files.
|
|
3
|
+
//
|
|
4
|
+
// These files are standard JSON — JSON.parse with Zod schema validation is sufficient.
|
|
5
|
+
// Both parsers return a discriminated union (ParseResult) and never throw.
|
|
6
|
+
//
|
|
7
|
+
// References:
|
|
8
|
+
// https://github.com/starkat99/unreal-schema/blob/main/uproject.schema.json
|
|
9
|
+
// https://github.com/starkat99/unreal-schema/blob/main/uplugin.schema.json
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// .uproject schemas
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export const UProjectModuleSchema = z.object({
|
|
15
|
+
Name: z.string(),
|
|
16
|
+
Type: z.string(),
|
|
17
|
+
LoadingPhase: z.string().optional(),
|
|
18
|
+
PlatformAllowList: z.array(z.string()).optional(),
|
|
19
|
+
PlatformDenyList: z.array(z.string()).optional(),
|
|
20
|
+
TargetAllowList: z.array(z.string()).optional(),
|
|
21
|
+
TargetDenyList: z.array(z.string()).optional(),
|
|
22
|
+
TargetConfigurationAllowList: z.array(z.string()).optional(),
|
|
23
|
+
TargetConfigurationDenyList: z.array(z.string()).optional(),
|
|
24
|
+
});
|
|
25
|
+
export const UProjectPluginEntrySchema = z.object({
|
|
26
|
+
Name: z.string(),
|
|
27
|
+
Enabled: z.boolean(),
|
|
28
|
+
Optional: z.boolean().optional(),
|
|
29
|
+
Description: z.string().optional(),
|
|
30
|
+
MarketplaceURL: z.string().optional(),
|
|
31
|
+
PlatformAllowList: z.array(z.string()).optional(),
|
|
32
|
+
SupportedTargetPlatforms: z.array(z.string()).optional(),
|
|
33
|
+
});
|
|
34
|
+
export const UProjectSchema = z.object({
|
|
35
|
+
FileVersion: z.number().int().min(1).max(3),
|
|
36
|
+
EngineAssociation: z.string().optional(),
|
|
37
|
+
Category: z.string().optional(),
|
|
38
|
+
Description: z.string().optional(),
|
|
39
|
+
Enterprise: z.boolean().optional(),
|
|
40
|
+
DisableEnginePluginsByDefault: z.boolean().optional(),
|
|
41
|
+
Modules: z.array(UProjectModuleSchema).optional(),
|
|
42
|
+
Plugins: z.array(UProjectPluginEntrySchema).optional(),
|
|
43
|
+
AdditionalPluginDirectories: z.array(z.string()).optional(),
|
|
44
|
+
AdditionalRootDirectories: z.array(z.string()).optional(),
|
|
45
|
+
TargetPlatforms: z.array(z.string()).optional(),
|
|
46
|
+
});
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// .uplugin schemas
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
export const UPluginModuleSchema = z.object({
|
|
51
|
+
Name: z.string(),
|
|
52
|
+
Type: z.string(),
|
|
53
|
+
LoadingPhase: z.string().optional(),
|
|
54
|
+
PlatformAllowList: z.array(z.string()).optional(),
|
|
55
|
+
PlatformDenyList: z.array(z.string()).optional(),
|
|
56
|
+
});
|
|
57
|
+
export const UPluginDependencySchema = z.object({
|
|
58
|
+
Name: z.string(),
|
|
59
|
+
Enabled: z.boolean(),
|
|
60
|
+
Optional: z.boolean().optional(),
|
|
61
|
+
PlatformAllowList: z.array(z.string()).optional(),
|
|
62
|
+
});
|
|
63
|
+
export const UPluginSchema = z.object({
|
|
64
|
+
FileVersion: z.number().int().min(1).max(3),
|
|
65
|
+
Version: z.number().optional(),
|
|
66
|
+
VersionName: z.string().optional(),
|
|
67
|
+
FriendlyName: z.string().optional(),
|
|
68
|
+
Description: z.string().optional(),
|
|
69
|
+
Category: z.string().optional(),
|
|
70
|
+
CreatedBy: z.string().optional(),
|
|
71
|
+
EngineVersion: z.string().optional(),
|
|
72
|
+
SupportedTargetPlatforms: z.array(z.string()).optional(),
|
|
73
|
+
Modules: z.array(UPluginModuleSchema).optional(),
|
|
74
|
+
Plugins: z.array(UPluginDependencySchema).optional(),
|
|
75
|
+
EnabledByDefault: z.boolean().optional(),
|
|
76
|
+
CanContainContent: z.boolean().optional(),
|
|
77
|
+
IsBetaVersion: z.boolean().optional(),
|
|
78
|
+
IsExperimentalVersion: z.boolean().optional(),
|
|
79
|
+
});
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Parse functions
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* Parse a raw JSON string from a .uproject file.
|
|
85
|
+
* Returns ParseResult<UProjectDescriptor> — never throws.
|
|
86
|
+
*
|
|
87
|
+
* Threat T-02-03: JSON.parse is wrapped in try/catch; SyntaxError is never propagated.
|
|
88
|
+
*/
|
|
89
|
+
export function parseUproject(jsonContent) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(jsonContent);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return { success: false, error: `Invalid JSON: ${msg}` };
|
|
97
|
+
}
|
|
98
|
+
const result = UProjectSchema.safeParse(parsed);
|
|
99
|
+
if (result.success) {
|
|
100
|
+
return { success: true, data: result.data };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
error: result.error.issues.map((i) => i.message).join('; '),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parse a raw JSON string from a .uplugin file.
|
|
109
|
+
* Returns ParseResult<UPluginDescriptor> — never throws.
|
|
110
|
+
*
|
|
111
|
+
* Threat T-02-03: JSON.parse is wrapped in try/catch; SyntaxError is never propagated.
|
|
112
|
+
*/
|
|
113
|
+
export function parseUplugin(jsonContent) {
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(jsonContent);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
+
return { success: false, error: `Invalid JSON: ${msg}` };
|
|
121
|
+
}
|
|
122
|
+
const result = UPluginSchema.safeParse(parsed);
|
|
123
|
+
if (result.success) {
|
|
124
|
+
return { success: true, data: result.data };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: result.error.issues.map((i) => i.message).join('; '),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// TCP client connecting to the UE Editor plugin on PLUGIN_PORT.
|
|
2
|
+
// Returns structured plugin_not_connected errors when the editor is not running.
|
|
3
|
+
// Reconnects automatically with ExponentialBackoff — never floods logs (Pitfall 3).
|
|
4
|
+
// JSON-newline framing with correlationId-based request/response matching (Phase 7).
|
|
5
|
+
import * as net from 'net';
|
|
6
|
+
import { ExponentialBackoff } from './retry.js';
|
|
7
|
+
import { PLUGIN_PORT } from '../config.js';
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by sendCommand() when no connection to the UE Editor plugin exists.
|
|
10
|
+
* Catch this to surface a structured error to the MCP client instead of crashing.
|
|
11
|
+
*/
|
|
12
|
+
export class PluginNotConnectedError extends Error {
|
|
13
|
+
bridgeError;
|
|
14
|
+
constructor(bridgeError) {
|
|
15
|
+
super(bridgeError.message);
|
|
16
|
+
this.name = 'PluginNotConnectedError';
|
|
17
|
+
this.bridgeError = bridgeError;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// PluginBridgeClient
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/**
|
|
24
|
+
* TCP client that connects to the UE Editor MCPBridge plugin (port 55557 by default).
|
|
25
|
+
*
|
|
26
|
+
* Connection lifecycle:
|
|
27
|
+
* - Constructor starts scheduleReconnect() immediately (first attempt after 1s).
|
|
28
|
+
* - On connect: socket is set, backoff is reset.
|
|
29
|
+
* - On error/close: socket is cleared, next reconnect is scheduled.
|
|
30
|
+
* - Only logs on first failure and on state changes (never on every retry).
|
|
31
|
+
*
|
|
32
|
+
* Graceful degradation:
|
|
33
|
+
* - isConnected() returns false when no socket is active — never throws.
|
|
34
|
+
* - getDisconnectedError() returns the exact MCPBridgeError shape.
|
|
35
|
+
* - sendCommand() throws PluginNotConnectedError when disconnected.
|
|
36
|
+
*
|
|
37
|
+
* Protocol (JSON-newline framing with correlationId):
|
|
38
|
+
* - sendCommand() generates a UUID correlationId, writes JSON+\n to the socket.
|
|
39
|
+
* - The socket 'data' listener accumulates receiveBuffer, splits on \n, and
|
|
40
|
+
* resolves the matching pending promise by correlationId.
|
|
41
|
+
* - 10-second timeout rejects the promise if no response arrives.
|
|
42
|
+
* - destroy() rejects all pending commands to prevent leaks.
|
|
43
|
+
*/
|
|
44
|
+
export class PluginBridgeClient {
|
|
45
|
+
socket = null;
|
|
46
|
+
port;
|
|
47
|
+
backoff;
|
|
48
|
+
reconnectTimer = null;
|
|
49
|
+
/** Log first failure only; suppress subsequent retries until state changes. */
|
|
50
|
+
hasLoggedFirstFailure = false;
|
|
51
|
+
/** Accumulates partial data received from the TCP socket between newlines. */
|
|
52
|
+
receiveBuffer = '';
|
|
53
|
+
/**
|
|
54
|
+
* Maps correlationId to resolve/reject callbacks for in-flight sendCommand() calls.
|
|
55
|
+
* Entries are added before socket.write() and removed on response or timeout.
|
|
56
|
+
*/
|
|
57
|
+
pendingCommands = new Map();
|
|
58
|
+
constructor(port = PLUGIN_PORT) {
|
|
59
|
+
this.port = port;
|
|
60
|
+
this.backoff = new ExponentialBackoff();
|
|
61
|
+
this.scheduleReconnect();
|
|
62
|
+
}
|
|
63
|
+
/** Returns true only when a live (non-destroyed) socket exists. */
|
|
64
|
+
isConnected() {
|
|
65
|
+
return this.socket !== null && !this.socket.destroyed;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Returns the exact error object shape required by CONTEXT.md locked decision.
|
|
69
|
+
* Callers should include this object in their MCP tool response (isError: true).
|
|
70
|
+
*/
|
|
71
|
+
getDisconnectedError() {
|
|
72
|
+
return {
|
|
73
|
+
error: 'plugin_not_connected',
|
|
74
|
+
message: 'This tool requires the UE Editor plugin. Start the editor with the MCPBridge plugin enabled.',
|
|
75
|
+
required_plugin: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sends a command to the UE Editor plugin and waits for a correlated response.
|
|
80
|
+
*
|
|
81
|
+
* - Throws PluginNotConnectedError immediately if not connected.
|
|
82
|
+
* - Generates a crypto.randomUUID() correlationId (overwrites any caller-supplied value).
|
|
83
|
+
* - Writes JSON-newline framed message: JSON.stringify(cmd) + '\n'.
|
|
84
|
+
* - Resolves when the matching correlationId response arrives from the plugin.
|
|
85
|
+
* - Rejects with a timeout Error after 10 seconds if no response arrives.
|
|
86
|
+
* - Rejects immediately on socket write error.
|
|
87
|
+
*
|
|
88
|
+
* Threat T-07-11 mitigation: 10-second timeout clears Map entry on expiry;
|
|
89
|
+
* destroy() clears all remaining entries.
|
|
90
|
+
*/
|
|
91
|
+
async sendCommand(cmd) {
|
|
92
|
+
if (!this.isConnected()) {
|
|
93
|
+
throw new PluginNotConnectedError(this.getDisconnectedError());
|
|
94
|
+
}
|
|
95
|
+
// Generate a unique correlationId for this request — overwrites any caller value.
|
|
96
|
+
// crypto.randomUUID() is available globally in Node 19+ (Node 22 used here).
|
|
97
|
+
const correlationId = crypto.randomUUID();
|
|
98
|
+
const cmdWithId = { ...cmd, correlationId };
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
this.pendingCommands.set(correlationId, { resolve, reject });
|
|
101
|
+
// 10-second timeout — T-07-11 mitigation
|
|
102
|
+
const timeoutHandle = setTimeout(() => {
|
|
103
|
+
if (this.pendingCommands.has(correlationId)) {
|
|
104
|
+
this.pendingCommands.delete(correlationId);
|
|
105
|
+
reject(new Error(`MCP command '${cmd.type}' timed out after 10 seconds`));
|
|
106
|
+
}
|
|
107
|
+
}, 10_000);
|
|
108
|
+
// Write JSON-newline framed message to the socket.
|
|
109
|
+
// The write callback fires on flush; reject immediately on error.
|
|
110
|
+
this.socket.write(JSON.stringify(cmdWithId) + '\n', 'utf8', (err) => {
|
|
111
|
+
if (err) {
|
|
112
|
+
clearTimeout(timeoutHandle);
|
|
113
|
+
this.pendingCommands.delete(correlationId);
|
|
114
|
+
reject(err);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Cancel the pending reconnect timer, reject all in-flight commands, and close the socket.
|
|
121
|
+
* Call this in tests or during graceful shutdown.
|
|
122
|
+
*/
|
|
123
|
+
destroy() {
|
|
124
|
+
if (this.reconnectTimer !== null) {
|
|
125
|
+
clearTimeout(this.reconnectTimer);
|
|
126
|
+
this.reconnectTimer = null;
|
|
127
|
+
}
|
|
128
|
+
if (this.socket !== null) {
|
|
129
|
+
this.socket.destroy();
|
|
130
|
+
this.socket = null;
|
|
131
|
+
}
|
|
132
|
+
// Reject all pending commands to prevent leaks (T-07-11 mitigation).
|
|
133
|
+
for (const [, pending] of this.pendingCommands) {
|
|
134
|
+
pending.reject(new Error('PluginBridgeClient destroyed'));
|
|
135
|
+
}
|
|
136
|
+
this.pendingCommands.clear();
|
|
137
|
+
this.receiveBuffer = '';
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Private: reconnect loop
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
scheduleReconnect() {
|
|
143
|
+
const delay = this.backoff.nextDelay();
|
|
144
|
+
this.reconnectTimer = setTimeout(() => {
|
|
145
|
+
this.reconnectTimer = null;
|
|
146
|
+
if (this.isConnected()) {
|
|
147
|
+
// Already connected — reset backoff and continue monitoring
|
|
148
|
+
this.backoff.reset();
|
|
149
|
+
this.scheduleReconnect();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.connect().then(() => {
|
|
153
|
+
// Connection succeeded
|
|
154
|
+
this.backoff.reset();
|
|
155
|
+
this.hasLoggedFirstFailure = false;
|
|
156
|
+
console.error('[UE MCP] Connected to UE Editor plugin on port', this.port);
|
|
157
|
+
this.scheduleReconnect();
|
|
158
|
+
}).catch(() => {
|
|
159
|
+
// Connection failed — log only on first failure or after reconnection
|
|
160
|
+
if (!this.hasLoggedFirstFailure) {
|
|
161
|
+
console.error(`[UE MCP] Cannot reach UE Editor plugin on port ${this.port}. Retrying with backoff...`);
|
|
162
|
+
this.hasLoggedFirstFailure = true;
|
|
163
|
+
}
|
|
164
|
+
this.scheduleReconnect();
|
|
165
|
+
});
|
|
166
|
+
}, delay);
|
|
167
|
+
}
|
|
168
|
+
connect() {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const socket = net.createConnection({ port: this.port, host: '127.0.0.1' });
|
|
171
|
+
// JSON-newline response parser — accumulates partial chunks, splits on \n,
|
|
172
|
+
// and resolves the correlated pending promise.
|
|
173
|
+
// Threat T-07-09 mitigation: try/catch around JSON.parse; unknown correlationId
|
|
174
|
+
// is silently dropped — no state corruption.
|
|
175
|
+
socket.on('data', (chunk) => {
|
|
176
|
+
this.receiveBuffer += chunk.toString('utf8');
|
|
177
|
+
let newlineIdx;
|
|
178
|
+
while ((newlineIdx = this.receiveBuffer.indexOf('\n')) !== -1) {
|
|
179
|
+
const line = this.receiveBuffer.slice(0, newlineIdx).trim();
|
|
180
|
+
this.receiveBuffer = this.receiveBuffer.slice(newlineIdx + 1);
|
|
181
|
+
if (line.length === 0) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const response = JSON.parse(line);
|
|
186
|
+
const pending = this.pendingCommands.get(response.correlationId);
|
|
187
|
+
if (pending) {
|
|
188
|
+
this.pendingCommands.delete(response.correlationId);
|
|
189
|
+
pending.resolve(response);
|
|
190
|
+
}
|
|
191
|
+
// Unknown correlationId — silently drop (T-07-09 mitigation)
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Non-JSON line — ignore (T-07-09 mitigation)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
socket.once('connect', () => {
|
|
199
|
+
this.socket = socket;
|
|
200
|
+
resolve();
|
|
201
|
+
});
|
|
202
|
+
socket.once('error', (err) => {
|
|
203
|
+
socket.destroy();
|
|
204
|
+
reject(err);
|
|
205
|
+
});
|
|
206
|
+
socket.once('close', () => {
|
|
207
|
+
if (this.socket === socket) {
|
|
208
|
+
this.socket = null;
|
|
209
|
+
// Log disconnection only if we were previously connected
|
|
210
|
+
console.error('[UE MCP] Lost connection to UE Editor plugin. Reconnecting...');
|
|
211
|
+
this.hasLoggedFirstFailure = false;
|
|
212
|
+
this.scheduleReconnect();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// JSON-newline framing types for communication with the UE C++ plugin.
|
|
2
|
+
// The UE plugin (Phase 7) runs a TCP server on PLUGIN_PORT that speaks this protocol:
|
|
3
|
+
// - Each message is a JSON object followed by a newline character (\n)
|
|
4
|
+
// - Client sends MCPCommand, server responds with MCPResponse
|
|
5
|
+
// - correlationId is generated by the client, echoed by the server for matching
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Exponential backoff for TCP reconnection to the UE Editor plugin.
|
|
2
|
+
// Delay doubles from 1s initial, capped at 30s to avoid reconnect storms.
|
|
3
|
+
// See: RESEARCH.md Pitfall 3 — TCP Reconnect Storm on Plugin Not Running
|
|
4
|
+
export class ExponentialBackoff {
|
|
5
|
+
delay = 1000;
|
|
6
|
+
maxDelay = 30000;
|
|
7
|
+
/**
|
|
8
|
+
* Returns the current delay in milliseconds, then doubles it for next call.
|
|
9
|
+
* Delay is capped at maxDelay (30000ms).
|
|
10
|
+
*/
|
|
11
|
+
nextDelay() {
|
|
12
|
+
const current = this.delay;
|
|
13
|
+
this.delay = Math.min(this.delay * 2, this.maxDelay);
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resets delay back to the initial value (1000ms).
|
|
18
|
+
* Call this when a connection succeeds.
|
|
19
|
+
*/
|
|
20
|
+
reset() {
|
|
21
|
+
this.delay = 1000;
|
|
22
|
+
}
|
|
23
|
+
}
|