unreal-engine-mcp-server 0.3.1 → 0.4.3
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/.env.production +1 -1
- package/.github/copilot-instructions.md +45 -0
- package/.github/workflows/publish-mcp.yml +1 -1
- package/README.md +22 -7
- package/dist/index.js +137 -46
- package/dist/prompts/index.d.ts +10 -3
- package/dist/prompts/index.js +186 -7
- package/dist/resources/actors.d.ts +19 -1
- package/dist/resources/actors.js +55 -64
- package/dist/resources/assets.d.ts +3 -2
- package/dist/resources/assets.js +117 -109
- package/dist/resources/levels.d.ts +21 -3
- package/dist/resources/levels.js +31 -56
- package/dist/tools/actors.d.ts +3 -14
- package/dist/tools/actors.js +246 -302
- package/dist/tools/animation.d.ts +57 -102
- package/dist/tools/animation.js +429 -450
- package/dist/tools/assets.d.ts +13 -2
- package/dist/tools/assets.js +58 -46
- package/dist/tools/audio.d.ts +22 -13
- package/dist/tools/audio.js +467 -121
- package/dist/tools/blueprint.d.ts +32 -13
- package/dist/tools/blueprint.js +699 -448
- package/dist/tools/build_environment_advanced.d.ts +0 -1
- package/dist/tools/build_environment_advanced.js +236 -87
- package/dist/tools/consolidated-tool-definitions.d.ts +232 -15
- package/dist/tools/consolidated-tool-definitions.js +124 -255
- package/dist/tools/consolidated-tool-handlers.js +749 -766
- package/dist/tools/debug.d.ts +72 -10
- package/dist/tools/debug.js +170 -36
- package/dist/tools/editor.d.ts +9 -2
- package/dist/tools/editor.js +30 -44
- package/dist/tools/foliage.d.ts +34 -15
- package/dist/tools/foliage.js +97 -107
- package/dist/tools/introspection.js +19 -21
- package/dist/tools/landscape.d.ts +1 -2
- package/dist/tools/landscape.js +311 -168
- package/dist/tools/level.d.ts +3 -28
- package/dist/tools/level.js +642 -192
- package/dist/tools/lighting.d.ts +14 -3
- package/dist/tools/lighting.js +236 -123
- package/dist/tools/materials.d.ts +25 -7
- package/dist/tools/materials.js +102 -79
- package/dist/tools/niagara.d.ts +10 -12
- package/dist/tools/niagara.js +74 -94
- package/dist/tools/performance.d.ts +12 -4
- package/dist/tools/performance.js +38 -79
- package/dist/tools/physics.d.ts +34 -10
- package/dist/tools/physics.js +364 -292
- package/dist/tools/rc.js +98 -24
- package/dist/tools/sequence.d.ts +1 -0
- package/dist/tools/sequence.js +146 -24
- package/dist/tools/ui.d.ts +31 -4
- package/dist/tools/ui.js +83 -66
- package/dist/tools/visual.d.ts +11 -0
- package/dist/tools/visual.js +245 -30
- package/dist/types/tool-types.d.ts +0 -6
- package/dist/types/tool-types.js +1 -8
- package/dist/unreal-bridge.d.ts +32 -2
- package/dist/unreal-bridge.js +621 -127
- package/dist/utils/elicitation.d.ts +57 -0
- package/dist/utils/elicitation.js +104 -0
- package/dist/utils/error-handler.d.ts +0 -33
- package/dist/utils/error-handler.js +4 -111
- package/dist/utils/http.d.ts +2 -22
- package/dist/utils/http.js +12 -75
- package/dist/utils/normalize.d.ts +4 -4
- package/dist/utils/normalize.js +15 -7
- package/dist/utils/python-output.d.ts +18 -0
- package/dist/utils/python-output.js +290 -0
- package/dist/utils/python.d.ts +2 -0
- package/dist/utils/python.js +4 -0
- package/dist/utils/response-validator.d.ts +6 -1
- package/dist/utils/response-validator.js +66 -13
- package/dist/utils/result-helpers.d.ts +27 -0
- package/dist/utils/result-helpers.js +147 -0
- package/dist/utils/safe-json.d.ts +0 -2
- package/dist/utils/safe-json.js +0 -43
- package/dist/utils/validation.d.ts +16 -0
- package/dist/utils/validation.js +70 -7
- package/mcp-config-example.json +2 -2
- package/package.json +11 -10
- package/server.json +37 -14
- package/src/index.ts +146 -50
- package/src/prompts/index.ts +211 -13
- package/src/resources/actors.ts +59 -44
- package/src/resources/assets.ts +123 -102
- package/src/resources/levels.ts +37 -47
- package/src/tools/actors.ts +269 -313
- package/src/tools/animation.ts +556 -539
- package/src/tools/assets.ts +59 -45
- package/src/tools/audio.ts +507 -113
- package/src/tools/blueprint.ts +778 -462
- package/src/tools/build_environment_advanced.ts +312 -106
- package/src/tools/consolidated-tool-definitions.ts +136 -267
- package/src/tools/consolidated-tool-handlers.ts +871 -795
- package/src/tools/debug.ts +179 -38
- package/src/tools/editor.ts +35 -37
- package/src/tools/foliage.ts +110 -104
- package/src/tools/introspection.ts +24 -22
- package/src/tools/landscape.ts +334 -181
- package/src/tools/level.ts +683 -182
- package/src/tools/lighting.ts +244 -123
- package/src/tools/materials.ts +114 -83
- package/src/tools/niagara.ts +87 -81
- package/src/tools/performance.ts +49 -88
- package/src/tools/physics.ts +393 -299
- package/src/tools/rc.ts +103 -25
- package/src/tools/sequence.ts +157 -30
- package/src/tools/ui.ts +101 -70
- package/src/tools/visual.ts +250 -29
- package/src/types/tool-types.ts +0 -9
- package/src/unreal-bridge.ts +658 -140
- package/src/utils/elicitation.ts +129 -0
- package/src/utils/error-handler.ts +4 -159
- package/src/utils/http.ts +16 -115
- package/src/utils/normalize.ts +20 -10
- package/src/utils/python-output.ts +351 -0
- package/src/utils/python.ts +3 -0
- package/src/utils/response-validator.ts +68 -17
- package/src/utils/result-helpers.ts +193 -0
- package/src/utils/safe-json.ts +0 -50
- package/src/utils/validation.ts +94 -7
- package/tests/run-unreal-tool-tests.mjs +720 -0
- package/tsconfig.json +2 -2
- package/dist/python-utils.d.ts +0 -29
- package/dist/python-utils.js +0 -54
- package/dist/tools/tool-definitions.d.ts +0 -4919
- package/dist/tools/tool-definitions.js +0 -1065
- package/dist/tools/tool-handlers.d.ts +0 -47
- package/dist/tools/tool-handlers.js +0 -863
- package/dist/types/index.d.ts +0 -323
- package/dist/types/index.js +0 -28
- package/dist/utils/cache-manager.d.ts +0 -64
- package/dist/utils/cache-manager.js +0 -176
- package/dist/utils/errors.d.ts +0 -133
- package/dist/utils/errors.js +0 -256
- package/src/python/editor_compat.py +0 -181
- package/src/python-utils.ts +0 -57
- package/src/tools/tool-definitions.ts +0 -1081
- package/src/tools/tool-handlers.ts +0 -973
- package/src/types/index.ts +0 -414
- package/src/utils/cache-manager.ts +0 -213
- package/src/utils/errors.ts +0 -312
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import JSON5 from 'json5';
|
|
2
|
+
|
|
3
|
+
export interface PythonOutput {
|
|
4
|
+
raw: unknown;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TaggedJsonResult<T> extends PythonOutput {
|
|
9
|
+
data: T | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StandardResultPayload {
|
|
13
|
+
success?: boolean;
|
|
14
|
+
message?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function escapeRegExp(value: string): string {
|
|
20
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stringifyAny(value: unknown): string {
|
|
24
|
+
if (typeof value === 'string') {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(value ?? '');
|
|
29
|
+
} catch {
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isTaggedResultLine(line: unknown, tag = 'RESULT:'): boolean {
|
|
35
|
+
if (typeof line !== 'string') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return line.includes(tag);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectLogOutput(logs: unknown): string | undefined {
|
|
42
|
+
if (!Array.isArray(logs)) return undefined;
|
|
43
|
+
let buffer = '';
|
|
44
|
+
for (const entry of logs) {
|
|
45
|
+
if (!entry) continue;
|
|
46
|
+
if (typeof entry === 'string') {
|
|
47
|
+
buffer += entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const output = (entry as any).Output ?? (entry as any).output ?? (entry as any).Message;
|
|
51
|
+
if (typeof output === 'string') {
|
|
52
|
+
buffer += output.endsWith('\n') ? output : `${output}\n`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return buffer || undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toPythonOutput(response: unknown): PythonOutput {
|
|
59
|
+
if (typeof response === 'string') {
|
|
60
|
+
return { raw: response, text: response };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (response == null) {
|
|
64
|
+
return { raw: response, text: '' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(response)) {
|
|
68
|
+
const lines = response.map(item => stringifyAny(item));
|
|
69
|
+
return { raw: response, text: lines.join('\n') };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof response === 'object') {
|
|
73
|
+
const obj = response as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
const logOutput = collectLogOutput(obj.LogOutput ?? obj.logOutput ?? obj.logs);
|
|
76
|
+
if (logOutput) {
|
|
77
|
+
return { raw: response, text: logOutput };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof obj.result === 'string') {
|
|
81
|
+
return { raw: response, text: obj.result };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof obj.ReturnValue === 'string') {
|
|
85
|
+
return { raw: response, text: obj.ReturnValue };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (obj.result !== undefined) {
|
|
89
|
+
return { raw: response, text: stringifyAny(obj.result) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (obj.ReturnValue !== undefined) {
|
|
93
|
+
return { raw: response, text: stringifyAny(obj.ReturnValue) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { raw: response, text: stringifyAny(obj) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { raw: response, text: String(response) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tryParseJson<T>(candidate: string): T | null {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(candidate) as T;
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
return JSON5.parse(candidate) as T;
|
|
108
|
+
} catch {
|
|
109
|
+
try {
|
|
110
|
+
const normalized = normalizeLegacyJson(candidate);
|
|
111
|
+
return JSON5.parse(normalized) as T;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeLegacyJson(candidate: string): string {
|
|
120
|
+
return candidate.replace(/\b(True|False|None)\b/g, (match) => {
|
|
121
|
+
switch (match) {
|
|
122
|
+
case 'True': return 'true';
|
|
123
|
+
case 'False': return 'false';
|
|
124
|
+
case 'None': return 'null';
|
|
125
|
+
default: return match;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findJsonBlock(text: string, startIndex: number): string | null {
|
|
131
|
+
const length = text.length;
|
|
132
|
+
let index = startIndex;
|
|
133
|
+
|
|
134
|
+
while (index < length) {
|
|
135
|
+
const currentChar = text[index];
|
|
136
|
+
if (!currentChar || !/\s/.test(currentChar)) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
index += 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (index >= length) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const opening = text[index];
|
|
147
|
+
if (!opening) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
let closing: string | null = null;
|
|
151
|
+
if (opening === '{') {
|
|
152
|
+
closing = '}';
|
|
153
|
+
} else if (opening === '[') {
|
|
154
|
+
closing = ']';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!closing) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let depth = 0;
|
|
162
|
+
let inString = false;
|
|
163
|
+
let stringQuote: string | null = null;
|
|
164
|
+
let escaped = false;
|
|
165
|
+
|
|
166
|
+
for (let i = index; i < length; i++) {
|
|
167
|
+
const char = text[i];
|
|
168
|
+
if (!char) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (inString) {
|
|
173
|
+
if (escaped) {
|
|
174
|
+
escaped = false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (char === '\\') {
|
|
179
|
+
escaped = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (char === stringQuote) {
|
|
184
|
+
inString = false;
|
|
185
|
+
stringQuote = null;
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (char === '"' || char === '\'') {
|
|
191
|
+
inString = true;
|
|
192
|
+
stringQuote = char;
|
|
193
|
+
escaped = false;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (char === opening) {
|
|
198
|
+
depth += 1;
|
|
199
|
+
} else if (char === closing) {
|
|
200
|
+
depth -= 1;
|
|
201
|
+
if (depth === 0) {
|
|
202
|
+
return text.slice(index, i + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractTaggedJson<T = unknown>(response: unknown, tag = 'RESULT:'): TaggedJsonResult<T> {
|
|
211
|
+
const output = toPythonOutput(response);
|
|
212
|
+
const pattern = escapeRegExp(tag);
|
|
213
|
+
|
|
214
|
+
let data: T | null = null;
|
|
215
|
+
|
|
216
|
+
const directMatch = output.text.match(new RegExp(`${pattern}\\s*(.+)$`, 'm'));
|
|
217
|
+
if (directMatch) {
|
|
218
|
+
const parsed = tryParseJson<T>(directMatch[1]);
|
|
219
|
+
if (parsed !== null) {
|
|
220
|
+
data = parsed;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (data === null) {
|
|
225
|
+
const tagIndex = output.text.indexOf(tag);
|
|
226
|
+
if (tagIndex >= 0) {
|
|
227
|
+
const jsonBlock = findJsonBlock(output.text, tagIndex + tag.length);
|
|
228
|
+
if (jsonBlock) {
|
|
229
|
+
const parsed = tryParseJson<T>(jsonBlock);
|
|
230
|
+
if (parsed !== null) {
|
|
231
|
+
data = parsed;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const sanitizedText = stripTaggedResultLines(output.text, tag);
|
|
238
|
+
const sanitizedRaw = sanitizePythonRaw(output.raw, tag);
|
|
239
|
+
return { raw: sanitizedRaw, text: sanitizedText, data };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function parseStandardResult(response: unknown, tag = 'RESULT:'): TaggedJsonResult<StandardResultPayload> {
|
|
243
|
+
return extractTaggedJson<StandardResultPayload>(response, tag);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function stripTaggedResultLines(text: string, tag = 'RESULT:'): string {
|
|
247
|
+
if (!text || !text.includes(tag)) {
|
|
248
|
+
return text;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lines = text.split(/\r?\n/);
|
|
252
|
+
const cleaned = lines.filter(line => !isTaggedResultLine(line, tag));
|
|
253
|
+
const collapsed = cleaned.join('\n');
|
|
254
|
+
return collapsed.replace(/\n{3,}/g, '\n\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function extractTaggedLine(output: string | PythonOutput, prefix: string): string | null {
|
|
258
|
+
const text = typeof output === 'string' ? output : output.text;
|
|
259
|
+
const pattern = new RegExp(`^${escapeRegExp(prefix)}\\s*(.+)$`, 'm');
|
|
260
|
+
const match = text.match(pattern);
|
|
261
|
+
return match ? match[1].trim() : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function sanitizePythonRaw(raw: unknown, tag = 'RESULT:'): unknown {
|
|
265
|
+
if (raw == null) {
|
|
266
|
+
return raw;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (typeof raw === 'string') {
|
|
270
|
+
return stripTaggedResultLines(raw, tag);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (Array.isArray(raw)) {
|
|
274
|
+
const sanitized = raw
|
|
275
|
+
.map(item => sanitizePythonRaw(item, tag))
|
|
276
|
+
.filter(item => {
|
|
277
|
+
if (typeof item === 'string') {
|
|
278
|
+
return item.length > 0 && !isTaggedResultLine(item, tag);
|
|
279
|
+
}
|
|
280
|
+
return item !== undefined;
|
|
281
|
+
});
|
|
282
|
+
return sanitized;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (typeof raw === 'object') {
|
|
286
|
+
const obj = raw as Record<string, unknown>;
|
|
287
|
+
let mutated = false;
|
|
288
|
+
const clone: Record<string, unknown> = { ...obj };
|
|
289
|
+
|
|
290
|
+
const sanitizeLogArray = (value: unknown[]): unknown[] => {
|
|
291
|
+
const filtered = value.filter(entry => {
|
|
292
|
+
if (entry == null) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
if (typeof entry === 'string') {
|
|
296
|
+
return !isTaggedResultLine(entry, tag);
|
|
297
|
+
}
|
|
298
|
+
const output = (entry as any).Output ?? (entry as any).output ?? (entry as any).Message;
|
|
299
|
+
if (typeof output === 'string' && isTaggedResultLine(output, tag)) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
return filtered;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (Array.isArray(obj.LogOutput)) {
|
|
308
|
+
const originalArray = obj.LogOutput as unknown[];
|
|
309
|
+
const sanitizedLog = sanitizeLogArray(originalArray);
|
|
310
|
+
if (
|
|
311
|
+
sanitizedLog.length !== originalArray.length ||
|
|
312
|
+
sanitizedLog.some((entry: unknown, idx: number) => entry !== originalArray[idx])
|
|
313
|
+
) {
|
|
314
|
+
clone.LogOutput = sanitizedLog;
|
|
315
|
+
mutated = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (Array.isArray(obj.logs)) {
|
|
320
|
+
const originalLogs = obj.logs as unknown[];
|
|
321
|
+
const sanitizedLogs = sanitizeLogArray(originalLogs);
|
|
322
|
+
if (
|
|
323
|
+
sanitizedLogs.length !== originalLogs.length ||
|
|
324
|
+
sanitizedLogs.some((entry: unknown, idx: number) => entry !== originalLogs[idx])
|
|
325
|
+
) {
|
|
326
|
+
clone.logs = sanitizedLogs;
|
|
327
|
+
mutated = true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (typeof obj.Output === 'string') {
|
|
332
|
+
const stripped = stripTaggedResultLines(obj.Output, tag);
|
|
333
|
+
if (stripped !== obj.Output) {
|
|
334
|
+
clone.Output = stripped;
|
|
335
|
+
mutated = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (typeof obj.result === 'string') {
|
|
340
|
+
const strippedResult = stripTaggedResultLines(obj.result, tag);
|
|
341
|
+
if (strippedResult !== obj.result) {
|
|
342
|
+
clone.result = strippedResult;
|
|
343
|
+
mutated = true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return mutated ? clone : raw;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return raw;
|
|
351
|
+
}
|
|
@@ -95,40 +95,91 @@ export class ResponseValidator {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
* Wrap a tool response with validation
|
|
98
|
+
* Wrap a tool response with validation and MCP-compliant content shape.
|
|
99
|
+
*
|
|
100
|
+
* MCP tools/call responses must contain a `content` array. Many internal
|
|
101
|
+
* handlers return structured JSON objects (e.g., { success, message, ... }).
|
|
102
|
+
* This wrapper serializes such objects into a single text block while keeping
|
|
103
|
+
* existing `content` responses intact.
|
|
99
104
|
*/
|
|
100
105
|
wrapResponse(toolName: string, response: any): any {
|
|
101
106
|
// Ensure response is safe to serialize first
|
|
102
107
|
try {
|
|
103
|
-
// The response should already be cleaned, but double-check
|
|
104
108
|
if (response && typeof response === 'object') {
|
|
105
|
-
// Make sure we can serialize it
|
|
106
109
|
JSON.stringify(response);
|
|
107
110
|
}
|
|
108
111
|
} catch (_error) {
|
|
109
112
|
log.error(`Response for ${toolName} contains circular references, cleaning...`);
|
|
110
113
|
response = cleanObject(response);
|
|
111
114
|
}
|
|
112
|
-
|
|
115
|
+
|
|
116
|
+
// If handler already returned MCP content, keep it as-is (still validate)
|
|
117
|
+
const alreadyMcpShaped = response && typeof response === 'object' && Array.isArray(response.content);
|
|
118
|
+
|
|
119
|
+
// Choose the payload to validate: if already MCP-shaped, validate the
|
|
120
|
+
// structured content extracted from text; otherwise validate the object directly.
|
|
113
121
|
const validation = this.validateResponse(toolName, response);
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
const structuredPayload = validation.structuredContent;
|
|
123
|
+
|
|
116
124
|
if (!validation.valid) {
|
|
117
125
|
log.warn(`Tool ${toolName} response validation failed:`, validation.errors);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If it's already MCP-shaped, return as-is (optionally append validation meta)
|
|
129
|
+
if (alreadyMcpShaped) {
|
|
130
|
+
if (structuredPayload !== undefined && response && typeof response === 'object' && response.structuredContent === undefined) {
|
|
131
|
+
try {
|
|
132
|
+
(response as any).structuredContent = structuredPayload && typeof structuredPayload === 'object'
|
|
133
|
+
? cleanObject(structuredPayload)
|
|
134
|
+
: structuredPayload;
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
if (!validation.valid) {
|
|
138
|
+
try {
|
|
139
|
+
(response as any)._validation = { valid: false, errors: validation.errors };
|
|
140
|
+
} catch {}
|
|
125
141
|
}
|
|
142
|
+
return response;
|
|
126
143
|
}
|
|
127
144
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
// Otherwise, wrap structured result into MCP content
|
|
146
|
+
let text: string;
|
|
147
|
+
try {
|
|
148
|
+
// Pretty-print small objects for readability
|
|
149
|
+
text = typeof response === 'string'
|
|
150
|
+
? response
|
|
151
|
+
: JSON.stringify(response ?? { success: true }, null, 2);
|
|
152
|
+
} catch (_e) {
|
|
153
|
+
text = String(response);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const wrapped = {
|
|
157
|
+
content: [
|
|
158
|
+
{ type: 'text', text }
|
|
159
|
+
]
|
|
160
|
+
} as any;
|
|
161
|
+
|
|
162
|
+
if (structuredPayload !== undefined) {
|
|
163
|
+
try {
|
|
164
|
+
wrapped.structuredContent = structuredPayload && typeof structuredPayload === 'object'
|
|
165
|
+
? cleanObject(structuredPayload)
|
|
166
|
+
: structuredPayload;
|
|
167
|
+
} catch {
|
|
168
|
+
wrapped.structuredContent = structuredPayload;
|
|
169
|
+
}
|
|
170
|
+
} else if (response && typeof response === 'object') {
|
|
171
|
+
try {
|
|
172
|
+
wrapped.structuredContent = cleanObject(response);
|
|
173
|
+
} catch {
|
|
174
|
+
wrapped.structuredContent = response;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!validation.valid) {
|
|
179
|
+
wrapped._validation = { valid: false, errors: validation.errors };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return wrapped;
|
|
132
183
|
}
|
|
133
184
|
|
|
134
185
|
/**
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { parseStandardResult, StandardResultPayload, stripTaggedResultLines } from './python-output.js';
|
|
2
|
+
|
|
3
|
+
export interface InterpretedStandardResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
message: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
warnings?: string[];
|
|
8
|
+
details?: string[];
|
|
9
|
+
payload: StandardResultPayload & Record<string, unknown>;
|
|
10
|
+
cleanText?: string;
|
|
11
|
+
rawText: string;
|
|
12
|
+
raw: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function interpretStandardResult(
|
|
16
|
+
response: unknown,
|
|
17
|
+
defaults: { successMessage: string; failureMessage: string }
|
|
18
|
+
): InterpretedStandardResult {
|
|
19
|
+
const parsed = parseStandardResult(response);
|
|
20
|
+
const payload = (parsed.data ?? {}) as StandardResultPayload & Record<string, unknown>;
|
|
21
|
+
const success = payload.success === true;
|
|
22
|
+
const rawText = typeof parsed.text === 'string' ? parsed.text : String(parsed.text ?? '');
|
|
23
|
+
const cleanedText = cleanResultText(rawText, { fallback: undefined });
|
|
24
|
+
|
|
25
|
+
const messageFromPayload = typeof payload.message === 'string' ? payload.message.trim() : '';
|
|
26
|
+
const errorFromPayload = typeof payload.error === 'string' ? payload.error.trim() : '';
|
|
27
|
+
|
|
28
|
+
const message = messageFromPayload || (success ? defaults.successMessage : defaults.failureMessage);
|
|
29
|
+
const error = success ? undefined : errorFromPayload || messageFromPayload || defaults.failureMessage;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
success,
|
|
33
|
+
message,
|
|
34
|
+
error,
|
|
35
|
+
warnings: coerceStringArray(payload.warnings),
|
|
36
|
+
details: coerceStringArray(payload.details),
|
|
37
|
+
payload,
|
|
38
|
+
cleanText: cleanedText,
|
|
39
|
+
rawText,
|
|
40
|
+
raw: parsed.raw
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function cleanResultText(
|
|
45
|
+
text: string | undefined,
|
|
46
|
+
options: { tag?: string; fallback?: string } = {}
|
|
47
|
+
): string | undefined {
|
|
48
|
+
const { tag = 'RESULT:', fallback } = options;
|
|
49
|
+
if (!text) {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cleaned = stripTaggedResultLines(text, tag).trim();
|
|
54
|
+
if (cleaned.length > 0) {
|
|
55
|
+
return cleaned;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function bestEffortInterpretedText(
|
|
62
|
+
interpreted: Pick<InterpretedStandardResult, 'cleanText' | 'rawText'>,
|
|
63
|
+
fallback?: string
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const cleaned = interpreted.cleanText?.trim();
|
|
66
|
+
if (cleaned) {
|
|
67
|
+
return cleaned;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const raw = interpreted.rawText?.trim?.();
|
|
71
|
+
if (raw && !raw.startsWith('RESULT:')) {
|
|
72
|
+
return raw;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function coerceString(value: unknown): string | undefined {
|
|
79
|
+
if (typeof value === 'string') {
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function coerceStringArray(value: unknown): string[] | undefined {
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const items: string[] = [];
|
|
92
|
+
for (const entry of value) {
|
|
93
|
+
if (typeof entry !== 'string') {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const trimmed = entry.trim();
|
|
97
|
+
if (!trimmed) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
items.push(trimmed);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return items.length > 0 ? items : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function coerceBoolean(value: unknown, fallback?: boolean): boolean | undefined {
|
|
107
|
+
if (typeof value === 'boolean') {
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof value === 'string') {
|
|
112
|
+
const normalized = value.trim().toLowerCase();
|
|
113
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof value === 'number') {
|
|
122
|
+
if (value === 1) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (value === 0) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return fallback;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function coerceNumber(value: unknown): number | undefined {
|
|
134
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (typeof value === 'string') {
|
|
139
|
+
const parsed = Number(value);
|
|
140
|
+
if (Number.isFinite(parsed)) {
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function coerceVector3(value: unknown): [number, number, number] | undefined {
|
|
149
|
+
const toNumber = (entry: unknown): number | undefined => {
|
|
150
|
+
if (typeof entry === 'number' && Number.isFinite(entry)) {
|
|
151
|
+
return entry;
|
|
152
|
+
}
|
|
153
|
+
if (typeof entry === 'string') {
|
|
154
|
+
const parsed = Number(entry.trim());
|
|
155
|
+
if (Number.isFinite(parsed)) {
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const fromArray = (arr: unknown[]): [number, number, number] | undefined => {
|
|
163
|
+
if (arr.length !== 3) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const mapped = arr.map(toNumber);
|
|
167
|
+
if (mapped.every((entry): entry is number => typeof entry === 'number')) {
|
|
168
|
+
return mapped as [number, number, number];
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
const parsed = fromArray(value);
|
|
175
|
+
if (parsed) {
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (value && typeof value === 'object') {
|
|
181
|
+
const obj = value as Record<string, unknown>;
|
|
182
|
+
const candidate = fromArray([obj.x, obj.y, obj.z]);
|
|
183
|
+
if (candidate) {
|
|
184
|
+
return candidate;
|
|
185
|
+
}
|
|
186
|
+
const alternate = fromArray([obj.pitch, obj.yaw, obj.roll]);
|
|
187
|
+
if (alternate) {
|
|
188
|
+
return alternate;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
package/src/utils/safe-json.ts
CHANGED
|
@@ -1,53 +1,3 @@
|
|
|
1
|
-
// Utility to safely serialize objects with circular references
|
|
2
|
-
export function safeStringify(obj: any, space?: number): string {
|
|
3
|
-
const seen = new WeakSet();
|
|
4
|
-
|
|
5
|
-
return JSON.stringify(obj, (_key, value) => {
|
|
6
|
-
// Handle undefined, functions, symbols
|
|
7
|
-
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
|
8
|
-
return undefined;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Handle circular references
|
|
12
|
-
if (typeof value === 'object' && value !== null) {
|
|
13
|
-
if (seen.has(value)) {
|
|
14
|
-
return '[Circular Reference]';
|
|
15
|
-
}
|
|
16
|
-
seen.add(value);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Handle BigInt
|
|
20
|
-
if (typeof value === 'bigint') {
|
|
21
|
-
return value.toString();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return value;
|
|
25
|
-
}, space);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function sanitizeResponse(response: any): any {
|
|
29
|
-
if (!response || typeof response !== 'object') {
|
|
30
|
-
return response;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Create a clean copy without circular references
|
|
34
|
-
try {
|
|
35
|
-
const jsonStr = safeStringify(response);
|
|
36
|
-
return JSON.parse(jsonStr);
|
|
37
|
-
} catch (error) {
|
|
38
|
-
console.error('Failed to sanitize response:', error);
|
|
39
|
-
|
|
40
|
-
// Fallback: return a simple error response
|
|
41
|
-
return {
|
|
42
|
-
content: [{
|
|
43
|
-
type: 'text',
|
|
44
|
-
text: 'Response contained unserializable data'
|
|
45
|
-
}],
|
|
46
|
-
error: 'Serialization error'
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
1
|
// Remove circular references and non-serializable properties from an object
|
|
52
2
|
export function cleanObject(obj: any, maxDepth: number = 10): any {
|
|
53
3
|
const seen = new WeakSet();
|