blueprint-extractor-mcp 4.2.0 → 6.0.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/dist/automation-controller.js +4 -0
- package/dist/catalogs/example-catalog.js +2 -2
- package/dist/execution/adapters/commandlet-adapter.d.ts +24 -0
- package/dist/execution/adapters/commandlet-adapter.js +124 -0
- package/dist/execution/adapters/editor-adapter.d.ts +14 -0
- package/dist/execution/adapters/editor-adapter.js +24 -0
- package/dist/execution/adaptive-executor.d.ts +50 -0
- package/dist/execution/adaptive-executor.js +116 -0
- package/dist/execution/execution-adapter.d.ts +26 -0
- package/dist/execution/execution-adapter.js +10 -0
- package/dist/execution/execution-mode-detector.d.ts +16 -0
- package/dist/execution/execution-mode-detector.js +55 -0
- package/dist/execution/index.d.ts +5 -0
- package/dist/execution/index.js +5 -0
- package/dist/helpers/blueprint-validation.d.ts +17 -0
- package/dist/helpers/blueprint-validation.js +54 -0
- package/dist/helpers/formatting.d.ts +0 -1
- package/dist/helpers/formatting.js +8 -11
- package/dist/helpers/mutation-filter.d.ts +5 -0
- package/dist/helpers/mutation-filter.js +49 -0
- package/dist/helpers/next-step-hints.d.ts +13 -0
- package/dist/helpers/next-step-hints.js +882 -0
- package/dist/helpers/operation-deny-list.d.ts +15 -0
- package/dist/helpers/operation-deny-list.js +53 -0
- package/dist/helpers/phantom-filter.d.ts +18 -0
- package/dist/helpers/phantom-filter.js +56 -0
- package/dist/helpers/property-path-parser.d.ts +53 -0
- package/dist/helpers/property-path-parser.js +92 -0
- package/dist/helpers/response-summarizer.d.ts +17 -0
- package/dist/helpers/response-summarizer.js +181 -0
- package/dist/helpers/subsystem.js +3 -3
- package/dist/helpers/token-budget.d.ts +14 -0
- package/dist/helpers/token-budget.js +23 -0
- package/dist/helpers/tool-registration.d.ts +3 -1
- package/dist/helpers/tool-registration.js +11 -3
- package/dist/helpers/tool-results.js +28 -10
- package/dist/helpers/verification.js +9 -9
- package/dist/project-controller.d.ts +3 -0
- package/dist/project-controller.js +21 -4
- package/dist/register-server-tools.js +1 -9
- package/dist/resources/example-and-capture-resources.js +2 -2
- package/dist/schemas/tool-inputs.d.ts +1135 -3588
- package/dist/schemas/tool-inputs.js +62 -45
- package/dist/schemas/tool-results.d.ts +3974 -11890
- package/dist/schemas/tool-results.js +24 -24
- package/dist/server-config.d.ts +9 -0
- package/dist/server-config.js +149 -7
- package/dist/server-factory.d.ts +2 -0
- package/dist/server-factory.js +23 -4
- package/dist/tool-surface-manager.d.ts +1 -1
- package/dist/tool-surface-manager.js +86 -47
- package/dist/tools/automation-runs.js +1 -1
- package/dist/tools/blueprint-authoring.js +23 -0
- package/dist/tools/composite-tools.js +8 -3
- package/dist/tools/extraction.d.ts +1 -3
- package/dist/tools/extraction.js +145 -8
- package/dist/tools/import-jobs.d.ts +1 -6
- package/dist/tools/import-jobs.js +4 -50
- package/dist/tools/material-authoring.d.ts +1 -5
- package/dist/tools/material-authoring.js +3 -10
- package/dist/tools/widget-verification.js +1 -1
- package/package.json +2 -2
|
@@ -120,6 +120,10 @@ export class AutomationController {
|
|
|
120
120
|
if (!request.automationFilter) {
|
|
121
121
|
throw new Error('run_automation_tests requires automation_filter');
|
|
122
122
|
}
|
|
123
|
+
const safeFilterPattern = /^[A-Za-z0-9_.+* -]+$/u;
|
|
124
|
+
if (!safeFilterPattern.test(request.automationFilter)) {
|
|
125
|
+
throw new Error('automationFilter contains invalid characters: only alphanumeric, dots, underscores, plus, asterisk, hyphen, and spaces are allowed');
|
|
126
|
+
}
|
|
123
127
|
const started = this.now();
|
|
124
128
|
const filterSlug = sanitizeSegment(request.automationFilter);
|
|
125
129
|
const runId = `${filterSlug}_${started.getTime()}_${randomUUID().slice(0, 8)}`;
|
|
@@ -560,8 +560,8 @@ export const exampleCatalog = {
|
|
|
560
560
|
}],
|
|
561
561
|
bindings: {
|
|
562
562
|
propertyBindings: [{
|
|
563
|
-
sourcePath:
|
|
564
|
-
targetPath:
|
|
563
|
+
sourcePath: 'SelectedGestureTag',
|
|
564
|
+
targetPath: 'MontageTag',
|
|
565
565
|
}],
|
|
566
566
|
},
|
|
567
567
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandletAdapter spawns UnrealEditor-Cmd.exe with -run=blueprintextractor
|
|
3
|
+
* and communicates via stdin/stdout JSON-RPC.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExecutionAdapter, ToolCapability } from '../execution-adapter.js';
|
|
6
|
+
export interface CommandletAdapterOptions {
|
|
7
|
+
engineRoot: string;
|
|
8
|
+
projectPath: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class CommandletAdapter implements ExecutionAdapter {
|
|
11
|
+
private options;
|
|
12
|
+
private process;
|
|
13
|
+
private requestId;
|
|
14
|
+
private pendingRequests;
|
|
15
|
+
private buffer;
|
|
16
|
+
constructor(options: CommandletAdapterOptions);
|
|
17
|
+
initialize(): Promise<void>;
|
|
18
|
+
execute(_subsystem: string, method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
19
|
+
isAvailable(): Promise<boolean>;
|
|
20
|
+
getMode(): 'commandlet';
|
|
21
|
+
getCapabilities(): ReadonlySet<ToolCapability>;
|
|
22
|
+
shutdown(): Promise<void>;
|
|
23
|
+
private processBuffer;
|
|
24
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandletAdapter spawns UnrealEditor-Cmd.exe with -run=blueprintextractor
|
|
3
|
+
* and communicates via stdin/stdout JSON-RPC.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { COMMANDLET_CAPABILITIES } from '../execution-adapter.js';
|
|
7
|
+
const STARTUP_TIMEOUT_MS = 30_000;
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
9
|
+
export class CommandletAdapter {
|
|
10
|
+
options;
|
|
11
|
+
process = null;
|
|
12
|
+
requestId = 0;
|
|
13
|
+
pendingRequests = new Map();
|
|
14
|
+
buffer = '';
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async initialize() {
|
|
19
|
+
if (this.process)
|
|
20
|
+
return;
|
|
21
|
+
const editorCmd = `${this.options.engineRoot}/Binaries/Win64/UnrealEditor-Cmd.exe`;
|
|
22
|
+
this.process = spawn(editorCmd, [
|
|
23
|
+
this.options.projectPath,
|
|
24
|
+
'-run=blueprintextractor',
|
|
25
|
+
'-stdin',
|
|
26
|
+
'-unattended',
|
|
27
|
+
'-nosplash',
|
|
28
|
+
'-nullrhi',
|
|
29
|
+
], {
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
this.process.stdout?.on('data', (data) => {
|
|
33
|
+
this.buffer += data.toString();
|
|
34
|
+
this.processBuffer();
|
|
35
|
+
});
|
|
36
|
+
this.process.on('exit', () => {
|
|
37
|
+
this.process = null;
|
|
38
|
+
// Reject all pending requests
|
|
39
|
+
for (const [, pending] of this.pendingRequests) {
|
|
40
|
+
clearTimeout(pending.timer);
|
|
41
|
+
pending.reject(new Error('Commandlet process exited'));
|
|
42
|
+
}
|
|
43
|
+
this.pendingRequests.clear();
|
|
44
|
+
});
|
|
45
|
+
// Wait for startup
|
|
46
|
+
await new Promise((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
reject(new Error(`Commandlet startup timed out after ${STARTUP_TIMEOUT_MS}ms`));
|
|
49
|
+
}, STARTUP_TIMEOUT_MS);
|
|
50
|
+
const onData = () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
this.process?.stdout?.off('data', onData);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
// Resolve on first stdout output (indicating process is ready)
|
|
56
|
+
this.process?.stdout?.on('data', onData);
|
|
57
|
+
this.process?.on('error', (err) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(err);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async execute(_subsystem, method, params) {
|
|
64
|
+
if (!this.process?.stdin) {
|
|
65
|
+
throw new Error('Commandlet process not running. Call initialize() first.');
|
|
66
|
+
}
|
|
67
|
+
const id = ++this.requestId;
|
|
68
|
+
const request = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
this.pendingRequests.delete(id);
|
|
72
|
+
reject(new Error(`Commandlet request timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
73
|
+
}, REQUEST_TIMEOUT_MS);
|
|
74
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
75
|
+
this.process.stdin.write(request + '\n');
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async isAvailable() {
|
|
79
|
+
return this.process !== null && !this.process.killed;
|
|
80
|
+
}
|
|
81
|
+
getMode() {
|
|
82
|
+
return 'commandlet';
|
|
83
|
+
}
|
|
84
|
+
getCapabilities() {
|
|
85
|
+
return COMMANDLET_CAPABILITIES;
|
|
86
|
+
}
|
|
87
|
+
async shutdown() {
|
|
88
|
+
if (this.process) {
|
|
89
|
+
this.process.stdin?.end();
|
|
90
|
+
this.process.kill();
|
|
91
|
+
this.process = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
processBuffer() {
|
|
95
|
+
const lines = this.buffer.split('\n');
|
|
96
|
+
this.buffer = lines.pop() ?? '';
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const trimmed = line.trim();
|
|
99
|
+
if (!trimmed)
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
const response = JSON.parse(trimmed);
|
|
103
|
+
if (response.id != null && this.pendingRequests.has(response.id)) {
|
|
104
|
+
const pending = this.pendingRequests.get(response.id);
|
|
105
|
+
this.pendingRequests.delete(response.id);
|
|
106
|
+
clearTimeout(pending.timer);
|
|
107
|
+
if (response.error) {
|
|
108
|
+
pending.reject(new Error(typeof response.error === 'string'
|
|
109
|
+
? response.error
|
|
110
|
+
: JSON.stringify(response.error)));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
pending.resolve((typeof response.result === 'object' && response.result !== null)
|
|
114
|
+
? response.result
|
|
115
|
+
: { result: response.result });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Non-JSON output — skip (may be engine log lines)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditorAdapter wraps the existing UEClient with the ExecutionAdapter interface.
|
|
3
|
+
* This is a thin wrapper — all existing behavior is preserved.
|
|
4
|
+
*/
|
|
5
|
+
import type { UEClient } from '../../ue-client.js';
|
|
6
|
+
import type { ExecutionAdapter, ToolCapability } from '../execution-adapter.js';
|
|
7
|
+
export declare class EditorAdapter implements ExecutionAdapter {
|
|
8
|
+
private client;
|
|
9
|
+
constructor(client: UEClient);
|
|
10
|
+
execute(_subsystem: string, method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
11
|
+
isAvailable(): Promise<boolean>;
|
|
12
|
+
getMode(): 'editor';
|
|
13
|
+
getCapabilities(): ReadonlySet<ToolCapability>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditorAdapter wraps the existing UEClient with the ExecutionAdapter interface.
|
|
3
|
+
* This is a thin wrapper — all existing behavior is preserved.
|
|
4
|
+
*/
|
|
5
|
+
import { ALL_CAPABILITIES } from '../execution-adapter.js';
|
|
6
|
+
export class EditorAdapter {
|
|
7
|
+
client;
|
|
8
|
+
constructor(client) {
|
|
9
|
+
this.client = client;
|
|
10
|
+
}
|
|
11
|
+
async execute(_subsystem, method, params) {
|
|
12
|
+
const rawResult = await this.client.callSubsystem(method, params);
|
|
13
|
+
return JSON.parse(rawResult);
|
|
14
|
+
}
|
|
15
|
+
async isAvailable() {
|
|
16
|
+
return this.client.checkConnection();
|
|
17
|
+
}
|
|
18
|
+
getMode() {
|
|
19
|
+
return 'editor';
|
|
20
|
+
}
|
|
21
|
+
getCapabilities() {
|
|
22
|
+
return ALL_CAPABILITIES;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdaptiveExecutor routes tool calls to the best available adapter.
|
|
3
|
+
* Falls back from editor to commandlet for compatible operations.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExecutionAdapter, ToolCapability, ExecutionMode } from './execution-adapter.js';
|
|
6
|
+
import { ExecutionModeDetector } from './execution-mode-detector.js';
|
|
7
|
+
export type ToolModeAnnotation = 'both' | 'editor_only' | 'read_only';
|
|
8
|
+
export type EditorFallbackCaller = (method: string, params: Record<string, unknown>, options?: {
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}) => Promise<Record<string, unknown>>;
|
|
11
|
+
export declare class AdaptiveExecutor {
|
|
12
|
+
private editorAdapter;
|
|
13
|
+
private commandletAdapter;
|
|
14
|
+
private detector;
|
|
15
|
+
private toolModes;
|
|
16
|
+
/**
|
|
17
|
+
* Active tool name set by the tool registration wrapper before a handler runs.
|
|
18
|
+
* ASSUMPTION: The MCP SDK processes tool calls sequentially per transport
|
|
19
|
+
* connection (single Node.js event loop, SDK awaits each handler). If the SDK
|
|
20
|
+
* ever supports concurrent tool execution, this shared state must be replaced
|
|
21
|
+
* with per-request context (e.g., AsyncLocalStorage or passing toolName as a
|
|
22
|
+
* parameter to executeRouted).
|
|
23
|
+
*/
|
|
24
|
+
private _activeToolName;
|
|
25
|
+
constructor(editorAdapter: ExecutionAdapter, commandletAdapter: ExecutionAdapter | null, detector: ExecutionModeDetector);
|
|
26
|
+
setToolMode(toolName: string, mode: ToolModeAnnotation): void;
|
|
27
|
+
getToolMode(toolName: string): ToolModeAnnotation;
|
|
28
|
+
getCurrentMode(): Promise<ExecutionMode>;
|
|
29
|
+
/** Set the active tool name before a handler runs. Cleared after handler completes. */
|
|
30
|
+
setActiveToolName(name: string | null): void;
|
|
31
|
+
getActiveToolName(): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Route a callSubsystemJson-shaped call through the executor.
|
|
34
|
+
* For editor mode, delegates to the provided editorFallback (the original
|
|
35
|
+
* callSubsystemJson with its error-checking layer intact).
|
|
36
|
+
* For commandlet mode, routes through the commandlet adapter.
|
|
37
|
+
* This allows transparent interception without changing tool call sites.
|
|
38
|
+
*/
|
|
39
|
+
executeRouted(editorFallback: EditorFallbackCaller, method: string, params: Record<string, unknown>, options?: {
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}): Promise<Record<string, unknown>>;
|
|
42
|
+
execute(toolName: string, subsystem: string, method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
43
|
+
}
|
|
44
|
+
export declare class ExecutorError extends Error {
|
|
45
|
+
code: string;
|
|
46
|
+
toolName: string;
|
|
47
|
+
currentMode: ExecutionMode;
|
|
48
|
+
requiredCapability: ToolCapability;
|
|
49
|
+
constructor(code: string, message: string, toolName: string, currentMode: ExecutionMode, requiredCapability: ToolCapability);
|
|
50
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdaptiveExecutor routes tool calls to the best available adapter.
|
|
3
|
+
* Falls back from editor to commandlet for compatible operations.
|
|
4
|
+
*/
|
|
5
|
+
export class AdaptiveExecutor {
|
|
6
|
+
editorAdapter;
|
|
7
|
+
commandletAdapter;
|
|
8
|
+
detector;
|
|
9
|
+
toolModes = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Active tool name set by the tool registration wrapper before a handler runs.
|
|
12
|
+
* ASSUMPTION: The MCP SDK processes tool calls sequentially per transport
|
|
13
|
+
* connection (single Node.js event loop, SDK awaits each handler). If the SDK
|
|
14
|
+
* ever supports concurrent tool execution, this shared state must be replaced
|
|
15
|
+
* with per-request context (e.g., AsyncLocalStorage or passing toolName as a
|
|
16
|
+
* parameter to executeRouted).
|
|
17
|
+
*/
|
|
18
|
+
_activeToolName = null;
|
|
19
|
+
constructor(editorAdapter, commandletAdapter, detector) {
|
|
20
|
+
this.editorAdapter = editorAdapter;
|
|
21
|
+
this.commandletAdapter = commandletAdapter;
|
|
22
|
+
this.detector = detector;
|
|
23
|
+
}
|
|
24
|
+
setToolMode(toolName, mode) {
|
|
25
|
+
this.toolModes.set(toolName, mode);
|
|
26
|
+
}
|
|
27
|
+
getToolMode(toolName) {
|
|
28
|
+
return this.toolModes.get(toolName) ?? 'editor_only';
|
|
29
|
+
}
|
|
30
|
+
getCurrentMode() {
|
|
31
|
+
return this.detector.detect().then((r) => r.mode);
|
|
32
|
+
}
|
|
33
|
+
/** Set the active tool name before a handler runs. Cleared after handler completes. */
|
|
34
|
+
setActiveToolName(name) {
|
|
35
|
+
this._activeToolName = name;
|
|
36
|
+
}
|
|
37
|
+
getActiveToolName() {
|
|
38
|
+
return this._activeToolName;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Route a callSubsystemJson-shaped call through the executor.
|
|
42
|
+
* For editor mode, delegates to the provided editorFallback (the original
|
|
43
|
+
* callSubsystemJson with its error-checking layer intact).
|
|
44
|
+
* For commandlet mode, routes through the commandlet adapter.
|
|
45
|
+
* This allows transparent interception without changing tool call sites.
|
|
46
|
+
*/
|
|
47
|
+
async executeRouted(editorFallback, method, params, options) {
|
|
48
|
+
const toolName = this._activeToolName;
|
|
49
|
+
const detection = await this.detector.detect();
|
|
50
|
+
// If no active tool context or editor mode, use the original path
|
|
51
|
+
// (preserves callSubsystemJson error-checking layer)
|
|
52
|
+
if (!toolName || detection.mode === 'editor') {
|
|
53
|
+
return editorFallback(method, params, options);
|
|
54
|
+
}
|
|
55
|
+
const toolMode = this.getToolMode(toolName);
|
|
56
|
+
const requiredCapability = toolMode === 'read_only'
|
|
57
|
+
? 'read'
|
|
58
|
+
: toolMode === 'both'
|
|
59
|
+
? 'write_simple'
|
|
60
|
+
: 'write_complex';
|
|
61
|
+
// Try commandlet for compatible tools
|
|
62
|
+
if (detection.mode === 'commandlet' && this.commandletAdapter) {
|
|
63
|
+
if (toolMode === 'editor_only') {
|
|
64
|
+
throw new ExecutorError('CAPABILITY_MISMATCH', `Tool '${toolName}' requires the Unreal Editor but only commandlet mode is available. ${detection.reason}`, toolName, detection.mode, requiredCapability);
|
|
65
|
+
}
|
|
66
|
+
const capabilities = this.commandletAdapter.getCapabilities();
|
|
67
|
+
if (!capabilities.has(requiredCapability) && requiredCapability !== 'write_simple') {
|
|
68
|
+
throw new ExecutorError('CAPABILITY_MISMATCH', `Tool '${toolName}' requires '${requiredCapability}' capability which commandlet mode does not support.`, toolName, detection.mode, requiredCapability);
|
|
69
|
+
}
|
|
70
|
+
return this.commandletAdapter.execute('BlueprintExtractor', method, params);
|
|
71
|
+
}
|
|
72
|
+
// No adapter available
|
|
73
|
+
throw new ExecutorError('MODE_UNAVAILABLE', `No execution mode available for tool '${toolName}'. ${detection.reason}`, toolName, detection.mode, requiredCapability);
|
|
74
|
+
}
|
|
75
|
+
async execute(toolName, subsystem, method, params) {
|
|
76
|
+
const detection = await this.detector.detect();
|
|
77
|
+
const toolMode = this.getToolMode(toolName);
|
|
78
|
+
// Determine required capability from tool mode
|
|
79
|
+
const requiredCapability = toolMode === 'read_only'
|
|
80
|
+
? 'read'
|
|
81
|
+
: toolMode === 'both'
|
|
82
|
+
? 'write_simple'
|
|
83
|
+
: 'write_complex';
|
|
84
|
+
// Try editor first
|
|
85
|
+
if (detection.mode === 'editor') {
|
|
86
|
+
return this.editorAdapter.execute(subsystem, method, params);
|
|
87
|
+
}
|
|
88
|
+
// Try commandlet for compatible tools
|
|
89
|
+
if (detection.mode === 'commandlet' && this.commandletAdapter) {
|
|
90
|
+
if (toolMode === 'editor_only') {
|
|
91
|
+
throw new ExecutorError('CAPABILITY_MISMATCH', `Tool '${toolName}' requires the Unreal Editor but only commandlet mode is available. ${detection.reason}`, toolName, detection.mode, requiredCapability);
|
|
92
|
+
}
|
|
93
|
+
const capabilities = this.commandletAdapter.getCapabilities();
|
|
94
|
+
if (!capabilities.has(requiredCapability) && requiredCapability !== 'write_simple') {
|
|
95
|
+
throw new ExecutorError('CAPABILITY_MISMATCH', `Tool '${toolName}' requires '${requiredCapability}' capability which commandlet mode does not support.`, toolName, detection.mode, requiredCapability);
|
|
96
|
+
}
|
|
97
|
+
return this.commandletAdapter.execute(subsystem, method, params);
|
|
98
|
+
}
|
|
99
|
+
// No adapter available
|
|
100
|
+
throw new ExecutorError('MODE_UNAVAILABLE', `No execution mode available for tool '${toolName}'. ${detection.reason}`, toolName, detection.mode, requiredCapability);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export class ExecutorError extends Error {
|
|
104
|
+
code;
|
|
105
|
+
toolName;
|
|
106
|
+
currentMode;
|
|
107
|
+
requiredCapability;
|
|
108
|
+
constructor(code, message, toolName, currentMode, requiredCapability) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = 'ExecutorError';
|
|
111
|
+
this.code = code;
|
|
112
|
+
this.toolName = toolName;
|
|
113
|
+
this.currentMode = currentMode;
|
|
114
|
+
this.requiredCapability = requiredCapability;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core execution adapter interface for dual-mode architecture.
|
|
3
|
+
* Adapters abstract the underlying execution mechanism (editor or commandlet).
|
|
4
|
+
*/
|
|
5
|
+
export type ExecutionMode = 'editor' | 'commandlet' | 'unavailable';
|
|
6
|
+
export type ToolCapability = 'read' | 'write_simple' | 'write_complex' | 'interactive';
|
|
7
|
+
export interface ExecutionAdapter {
|
|
8
|
+
/** Execute a subsystem method with parameters */
|
|
9
|
+
execute(subsystem: string, method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
10
|
+
/** Check if this adapter is currently available */
|
|
11
|
+
isAvailable(): Promise<boolean>;
|
|
12
|
+
/** Get the execution mode this adapter provides */
|
|
13
|
+
getMode(): ExecutionMode;
|
|
14
|
+
/** Get the capabilities this adapter supports */
|
|
15
|
+
getCapabilities(): ReadonlySet<ToolCapability>;
|
|
16
|
+
/** Optional initialization */
|
|
17
|
+
initialize?(): Promise<void>;
|
|
18
|
+
/** Optional shutdown */
|
|
19
|
+
shutdown?(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface ModeDetectionResult {
|
|
22
|
+
mode: ExecutionMode;
|
|
23
|
+
reason: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const ALL_CAPABILITIES: ReadonlySet<ToolCapability>;
|
|
26
|
+
export declare const COMMANDLET_CAPABILITIES: ReadonlySet<ToolCapability>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core execution adapter interface for dual-mode architecture.
|
|
3
|
+
* Adapters abstract the underlying execution mechanism (editor or commandlet).
|
|
4
|
+
*/
|
|
5
|
+
export const ALL_CAPABILITIES = new Set([
|
|
6
|
+
'read', 'write_simple', 'write_complex', 'interactive',
|
|
7
|
+
]);
|
|
8
|
+
export const COMMANDLET_CAPABILITIES = new Set([
|
|
9
|
+
'read', 'write_simple',
|
|
10
|
+
]);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects the available execution mode by checking editor and commandlet availability.
|
|
3
|
+
* Caches the result for 5 seconds to avoid repeated probes.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExecutionAdapter, ModeDetectionResult } from './execution-adapter.js';
|
|
6
|
+
export declare class ExecutionModeDetector {
|
|
7
|
+
private editorAdapter;
|
|
8
|
+
private commandletAdapter;
|
|
9
|
+
private cachedResult;
|
|
10
|
+
private cachedAt;
|
|
11
|
+
private now;
|
|
12
|
+
constructor(editorAdapter: ExecutionAdapter, commandletAdapter?: ExecutionAdapter | null, now?: () => number);
|
|
13
|
+
detect(): Promise<ModeDetectionResult>;
|
|
14
|
+
invalidateCache(): void;
|
|
15
|
+
private cache;
|
|
16
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects the available execution mode by checking editor and commandlet availability.
|
|
3
|
+
* Caches the result for 5 seconds to avoid repeated probes.
|
|
4
|
+
*/
|
|
5
|
+
const CACHE_TTL_MS = 5_000;
|
|
6
|
+
export class ExecutionModeDetector {
|
|
7
|
+
editorAdapter;
|
|
8
|
+
commandletAdapter;
|
|
9
|
+
cachedResult = null;
|
|
10
|
+
cachedAt = 0;
|
|
11
|
+
now;
|
|
12
|
+
constructor(editorAdapter, commandletAdapter = null, now = Date.now) {
|
|
13
|
+
this.editorAdapter = editorAdapter;
|
|
14
|
+
this.commandletAdapter = commandletAdapter;
|
|
15
|
+
this.now = now;
|
|
16
|
+
}
|
|
17
|
+
async detect() {
|
|
18
|
+
const currentTime = this.now();
|
|
19
|
+
if (this.cachedResult && (currentTime - this.cachedAt) < CACHE_TTL_MS) {
|
|
20
|
+
return this.cachedResult;
|
|
21
|
+
}
|
|
22
|
+
// Check editor first (preferred)
|
|
23
|
+
try {
|
|
24
|
+
const editorAvailable = await this.editorAdapter.isAvailable();
|
|
25
|
+
if (editorAvailable) {
|
|
26
|
+
return this.cache({ mode: 'editor', reason: 'Editor Remote Control API is available' });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Editor not available
|
|
31
|
+
}
|
|
32
|
+
// Check commandlet fallback
|
|
33
|
+
if (this.commandletAdapter) {
|
|
34
|
+
try {
|
|
35
|
+
const cmdAvailable = await this.commandletAdapter.isAvailable();
|
|
36
|
+
if (cmdAvailable) {
|
|
37
|
+
return this.cache({ mode: 'commandlet', reason: 'Commandlet process is running (editor unavailable)' });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Commandlet not available
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return this.cache({ mode: 'unavailable', reason: 'Neither editor nor commandlet is available' });
|
|
45
|
+
}
|
|
46
|
+
invalidateCache() {
|
|
47
|
+
this.cachedResult = null;
|
|
48
|
+
this.cachedAt = 0;
|
|
49
|
+
}
|
|
50
|
+
cache(result) {
|
|
51
|
+
this.cachedResult = result;
|
|
52
|
+
this.cachedAt = this.now();
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { type ExecutionAdapter, type ExecutionMode, type ToolCapability, type ModeDetectionResult, ALL_CAPABILITIES, COMMANDLET_CAPABILITIES, } from './execution-adapter.js';
|
|
2
|
+
export { EditorAdapter } from './adapters/editor-adapter.js';
|
|
3
|
+
export { CommandletAdapter } from './adapters/commandlet-adapter.js';
|
|
4
|
+
export { ExecutionModeDetector } from './execution-mode-detector.js';
|
|
5
|
+
export { AdaptiveExecutor, ExecutorError } from './adaptive-executor.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ALL_CAPABILITIES, COMMANDLET_CAPABILITIES, } from './execution-adapter.js';
|
|
2
|
+
export { EditorAdapter } from './adapters/editor-adapter.js';
|
|
3
|
+
export { CommandletAdapter } from './adapters/commandlet-adapter.js';
|
|
4
|
+
export { ExecutionModeDetector } from './execution-mode-detector.js';
|
|
5
|
+
export { AdaptiveExecutor, ExecutorError } from './adaptive-executor.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type JsonSubsystemCaller = (method: string, params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
2
|
+
export interface StructuredValidationError {
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
recoverable: boolean;
|
|
7
|
+
next_steps: string[];
|
|
8
|
+
}
|
|
9
|
+
export type ValidationResult = {
|
|
10
|
+
valid: true;
|
|
11
|
+
} | {
|
|
12
|
+
valid: false;
|
|
13
|
+
error: StructuredValidationError;
|
|
14
|
+
};
|
|
15
|
+
export declare function isInheritedComponent(componentName: string, blueprintData: Record<string, unknown>): boolean;
|
|
16
|
+
export declare function validateInheritedComponents(assetPath: string, componentNames: string[], callSubsystemJson: JsonSubsystemCaller): Promise<ValidationResult>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function isInheritedComponent(componentName, blueprintData) {
|
|
2
|
+
const components = Array.isArray(blueprintData.components)
|
|
3
|
+
? blueprintData.components
|
|
4
|
+
: Array.isArray(blueprintData.Components)
|
|
5
|
+
? blueprintData.Components
|
|
6
|
+
: [];
|
|
7
|
+
for (const comp of components) {
|
|
8
|
+
if (typeof comp !== 'object' || comp === null)
|
|
9
|
+
continue;
|
|
10
|
+
const c = comp;
|
|
11
|
+
const name = c.name ?? c.Name ?? c.component_name ?? c.ComponentName;
|
|
12
|
+
const inherited = c.inherited ?? c.bInherited ?? c.is_inherited;
|
|
13
|
+
if (name === componentName && inherited === true) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
export async function validateInheritedComponents(assetPath, componentNames, callSubsystemJson) {
|
|
20
|
+
if (componentNames.length === 0) {
|
|
21
|
+
return { valid: true };
|
|
22
|
+
}
|
|
23
|
+
let blueprintData;
|
|
24
|
+
try {
|
|
25
|
+
blueprintData = await callSubsystemJson('ExtractBlueprint', {
|
|
26
|
+
AssetPath: assetPath,
|
|
27
|
+
Scope: 'Components',
|
|
28
|
+
bIncludeClassDefaults: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// If extraction fails, allow the operation to proceed — the C++ side
|
|
33
|
+
// will provide its own error. Don't block on a pre-validation failure.
|
|
34
|
+
return { valid: true };
|
|
35
|
+
}
|
|
36
|
+
for (const componentName of componentNames) {
|
|
37
|
+
if (isInheritedComponent(componentName, blueprintData)) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: {
|
|
41
|
+
code: 'INHERITED_COMPONENT',
|
|
42
|
+
message: `Component '${componentName}' in '${assetPath}' is inherited from a parent class and cannot be directly modified. Use the parent blueprint or UInheritableComponentHandler-compatible operations.`,
|
|
43
|
+
recoverable: true,
|
|
44
|
+
next_steps: [
|
|
45
|
+
`Modify the component in the parent blueprint that owns '${componentName}'.`,
|
|
46
|
+
'Use patch_component with override-aware parameters if the engine supports it.',
|
|
47
|
+
'Extract the parent blueprint to identify the owning class.',
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { valid: true };
|
|
54
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
3
|
-
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
4
3
|
export declare function coerceStringArray(value: unknown): string[];
|
|
5
4
|
export declare function formatPromptValue(value: unknown): string | null;
|
|
6
5
|
export declare function formatPromptList(label: string, value: unknown, fallback: string): string;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
export function isPlainObject(value) {
|
|
2
2
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
3
|
}
|
|
4
|
-
export function isRecord(value) {
|
|
5
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
-
}
|
|
7
4
|
export function coerceStringArray(value) {
|
|
8
5
|
if (Array.isArray(value)) {
|
|
9
6
|
return value.filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
|
|
@@ -59,17 +56,17 @@ export function tryParseJsonText(text) {
|
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
58
|
export function extractTextContent(result) {
|
|
62
|
-
if (!
|
|
59
|
+
if (!isPlainObject(result) || !Array.isArray(result.content)) {
|
|
63
60
|
return undefined;
|
|
64
61
|
}
|
|
65
|
-
const entry = result.content.find((candidate) =>
|
|
66
|
-
return
|
|
62
|
+
const entry = result.content.find((candidate) => isPlainObject(candidate) && candidate.type === 'text');
|
|
63
|
+
return isPlainObject(entry) && typeof entry.text === 'string' ? entry.text : undefined;
|
|
67
64
|
}
|
|
68
65
|
export function extractToolPayload(result) {
|
|
69
|
-
if (
|
|
66
|
+
if (isPlainObject(result) && 'structuredContent' in result) {
|
|
70
67
|
return result.structuredContent;
|
|
71
68
|
}
|
|
72
|
-
if (
|
|
69
|
+
if (isPlainObject(result) && Array.isArray(result.content)) {
|
|
73
70
|
const text = extractTextContent(result);
|
|
74
71
|
const parsed = tryParseJsonText(text);
|
|
75
72
|
if (parsed !== undefined) {
|
|
@@ -82,7 +79,7 @@ export function extractToolPayload(result) {
|
|
|
82
79
|
return result;
|
|
83
80
|
}
|
|
84
81
|
function isContentBlock(value) {
|
|
85
|
-
if (!
|
|
82
|
+
if (!isPlainObject(value) || typeof value.type !== 'string') {
|
|
86
83
|
return false;
|
|
87
84
|
}
|
|
88
85
|
switch (value.type) {
|
|
@@ -94,7 +91,7 @@ function isContentBlock(value) {
|
|
|
94
91
|
case 'resource_link':
|
|
95
92
|
return typeof value.uri === 'string' && typeof value.name === 'string';
|
|
96
93
|
case 'resource':
|
|
97
|
-
return
|
|
94
|
+
return isPlainObject(value.resource)
|
|
98
95
|
&& typeof value.resource.uri === 'string'
|
|
99
96
|
&& typeof value.resource.mimeType === 'string'
|
|
100
97
|
&& (typeof value.resource.text === 'string'
|
|
@@ -104,7 +101,7 @@ function isContentBlock(value) {
|
|
|
104
101
|
}
|
|
105
102
|
}
|
|
106
103
|
export function extractExtraContent(result) {
|
|
107
|
-
if (!
|
|
104
|
+
if (!isPlainObject(result) || !Array.isArray(result.content)) {
|
|
108
105
|
return [];
|
|
109
106
|
}
|
|
110
107
|
return result.content.filter((candidate) => (isContentBlock(candidate) && candidate.type !== 'text'));
|