blueprint-extractor-mcp 1.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/index.d.ts +2 -0
- package/dist/index.js +117 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.js +1 -0
- package/dist/ue-client.d.ts +11 -0
- package/dist/ue-client.js +98 -0
- package/package.json +26 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { UEClient } from './ue-client.js';
|
|
6
|
+
const client = new UEClient();
|
|
7
|
+
const server = new McpServer({
|
|
8
|
+
name: 'blueprint-extractor',
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
});
|
|
11
|
+
// Tool 1: extract_blueprint
|
|
12
|
+
server.tool('extract_blueprint', 'Extract a UE5 Blueprint asset to JSON. Returns the full Blueprint structure including class info, variables, components, and graph data.', {
|
|
13
|
+
asset_path: z.string().describe('UE asset path, e.g. /Game/Blueprints/BP_Character'),
|
|
14
|
+
scope: z.enum(['ClassLevel', 'Variables', 'Components', 'FunctionsShallow', 'Full', 'FullWithBytecode']).default('Full').describe('Extraction depth'),
|
|
15
|
+
}, async ({ asset_path, scope }) => {
|
|
16
|
+
try {
|
|
17
|
+
const result = await client.callSubsystem('ExtractBlueprint', { AssetPath: asset_path, Scope: scope });
|
|
18
|
+
const parsed = JSON.parse(result);
|
|
19
|
+
if (parsed.error) {
|
|
20
|
+
return { content: [{ type: 'text', text: `Error: ${parsed.error}` }], isError: true };
|
|
21
|
+
}
|
|
22
|
+
const text = JSON.stringify(parsed, null, 2);
|
|
23
|
+
if (text.length > 200_000) {
|
|
24
|
+
return { content: [{ type: 'text', text: `Warning: Response is ${(text.length / 1024).toFixed(0)}KB. Consider using a narrower scope.\n\n${text.substring(0, 200_000)}...\n[TRUNCATED]` }] };
|
|
25
|
+
}
|
|
26
|
+
return { content: [{ type: 'text', text }] };
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
// Tool 2: extract_statetree
|
|
33
|
+
server.tool('extract_statetree', 'Extract a UE5 StateTree asset to JSON. Returns the full state hierarchy, tasks, conditions, and transitions.', {
|
|
34
|
+
asset_path: z.string().describe('UE asset path to a StateTree, e.g. /Game/AI/ST_BotBehavior'),
|
|
35
|
+
}, async ({ asset_path }) => {
|
|
36
|
+
try {
|
|
37
|
+
const result = await client.callSubsystem('ExtractStateTree', { AssetPath: asset_path });
|
|
38
|
+
const parsed = JSON.parse(result);
|
|
39
|
+
if (parsed.error) {
|
|
40
|
+
return { content: [{ type: 'text', text: `Error: ${parsed.error}` }], isError: true };
|
|
41
|
+
}
|
|
42
|
+
return { content: [{ type: 'text', text: JSON.stringify(parsed, null, 2) }] };
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
// Tool 3: extract_cascade
|
|
49
|
+
server.tool('extract_cascade', 'Extract multiple Blueprint/StateTree assets with cascade reference following. Writes results to files on disk.', {
|
|
50
|
+
asset_paths: z.array(z.string()).describe('Array of UE asset paths to extract'),
|
|
51
|
+
scope: z.enum(['ClassLevel', 'Variables', 'Components', 'FunctionsShallow', 'Full', 'FullWithBytecode']).default('Full').describe('Extraction depth'),
|
|
52
|
+
max_depth: z.number().int().min(0).max(10).default(3).describe('How many levels deep to follow references'),
|
|
53
|
+
}, async ({ asset_paths, scope, max_depth }) => {
|
|
54
|
+
try {
|
|
55
|
+
const result = await client.callSubsystem('ExtractCascade', {
|
|
56
|
+
AssetPathsJson: JSON.stringify(asset_paths),
|
|
57
|
+
Scope: scope,
|
|
58
|
+
MaxDepth: max_depth,
|
|
59
|
+
});
|
|
60
|
+
const parsed = JSON.parse(result);
|
|
61
|
+
if (parsed.error) {
|
|
62
|
+
return { content: [{ type: 'text', text: `Error: ${parsed.error}` }], isError: true };
|
|
63
|
+
}
|
|
64
|
+
return { content: [{ type: 'text', text: `Extracted ${parsed.extracted_count} assets to ${parsed.output_directory}` }] };
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
// Tool 4: search_assets
|
|
71
|
+
server.tool('search_assets', 'Search for UE5 assets by name. Returns matching asset paths, names, and classes.', {
|
|
72
|
+
query: z.string().describe('Search query to match against asset names'),
|
|
73
|
+
class_filter: z.string().default('Blueprint').describe('Filter by asset class (e.g. Blueprint, StateTree, or empty for all)'),
|
|
74
|
+
}, async ({ query, class_filter }) => {
|
|
75
|
+
try {
|
|
76
|
+
const result = await client.callSubsystem('SearchAssets', { Query: query, ClassFilter: class_filter });
|
|
77
|
+
const parsed = JSON.parse(result);
|
|
78
|
+
if (parsed.error) {
|
|
79
|
+
return { content: [{ type: 'text', text: `Error: ${parsed.error}` }], isError: true };
|
|
80
|
+
}
|
|
81
|
+
return { content: [{ type: 'text', text: JSON.stringify(parsed, null, 2) }] };
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
// Tool 5: list_assets
|
|
88
|
+
server.tool('list_assets', 'List UE5 assets in a directory. Returns asset paths, names, and classes.', {
|
|
89
|
+
package_path: z.string().describe('UE package path, e.g. /Game/Blueprints'),
|
|
90
|
+
recursive: z.boolean().default(true).describe('Search subdirectories'),
|
|
91
|
+
class_filter: z.string().default('').describe('Filter by asset class (empty for all)'),
|
|
92
|
+
}, async ({ package_path, recursive, class_filter }) => {
|
|
93
|
+
try {
|
|
94
|
+
const result = await client.callSubsystem('ListAssets', {
|
|
95
|
+
PackagePath: package_path,
|
|
96
|
+
bRecursive: recursive,
|
|
97
|
+
ClassFilter: class_filter,
|
|
98
|
+
});
|
|
99
|
+
const parsed = JSON.parse(result);
|
|
100
|
+
if (parsed.error) {
|
|
101
|
+
return { content: [{ type: 'text', text: `Error: ${parsed.error}` }], isError: true };
|
|
102
|
+
}
|
|
103
|
+
return { content: [{ type: 'text', text: JSON.stringify(parsed, null, 2) }] };
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// Start server
|
|
110
|
+
async function main() {
|
|
111
|
+
const transport = new StdioServerTransport();
|
|
112
|
+
await server.connect(transport);
|
|
113
|
+
}
|
|
114
|
+
main().catch((e) => {
|
|
115
|
+
console.error('Fatal:', e);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface RemoteCallRequest {
|
|
2
|
+
objectPath: string;
|
|
3
|
+
functionName: string;
|
|
4
|
+
parameters: Record<string, unknown>;
|
|
5
|
+
generateTransaction: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface RemoteCallResponse {
|
|
8
|
+
ReturnValue?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface AssetInfo {
|
|
12
|
+
path: string;
|
|
13
|
+
name: string;
|
|
14
|
+
class: string;
|
|
15
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class UEClient {
|
|
2
|
+
private host;
|
|
3
|
+
private port;
|
|
4
|
+
private subsystemPath;
|
|
5
|
+
constructor();
|
|
6
|
+
private get baseUrl();
|
|
7
|
+
checkConnection(): Promise<boolean>;
|
|
8
|
+
discoverSubsystem(): Promise<string>;
|
|
9
|
+
private rawCall;
|
|
10
|
+
callSubsystem(method: string, params: Record<string, unknown>): Promise<string>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
2
|
+
const DEFAULT_PORT = 30010;
|
|
3
|
+
const TIMEOUT_MS = 60_000;
|
|
4
|
+
export class UEClient {
|
|
5
|
+
host;
|
|
6
|
+
port;
|
|
7
|
+
subsystemPath = null;
|
|
8
|
+
constructor() {
|
|
9
|
+
this.host = process.env.UE_REMOTE_CONTROL_HOST ?? DEFAULT_HOST;
|
|
10
|
+
this.port = parseInt(process.env.UE_REMOTE_CONTROL_PORT ?? String(DEFAULT_PORT), 10);
|
|
11
|
+
}
|
|
12
|
+
get baseUrl() {
|
|
13
|
+
return `http://${this.host}:${this.port}`;
|
|
14
|
+
}
|
|
15
|
+
async checkConnection() {
|
|
16
|
+
// GET /remote/info — if it responds, UE is running with Remote Control
|
|
17
|
+
try {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
20
|
+
const res = await fetch(`${this.baseUrl}/remote/info`, { signal: controller.signal });
|
|
21
|
+
clearTimeout(timeout);
|
|
22
|
+
return res.ok;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async discoverSubsystem() {
|
|
29
|
+
if (this.subsystemPath)
|
|
30
|
+
return this.subsystemPath;
|
|
31
|
+
// Use /remote/object/call to find the subsystem via GEditor
|
|
32
|
+
// EditorSubsystems are singletons accessible via GEditor->GetEditorSubsystem<T>()
|
|
33
|
+
// The object path for editor subsystems follows the pattern: /Engine/Transient.UnrealEditorSubsystem_0
|
|
34
|
+
// We try to call a method directly — if it works, the path is valid
|
|
35
|
+
// For UEditorSubsystem, the default object path is:
|
|
36
|
+
// /Engine/Transient.BlueprintExtractorSubsystem
|
|
37
|
+
// But the exact path depends on the engine. Let's try the standard pattern.
|
|
38
|
+
const candidatePaths = [
|
|
39
|
+
'/Script/BlueprintExtractor.Default__BlueprintExtractorSubsystem',
|
|
40
|
+
'/Engine/Transient.BlueprintExtractorSubsystem',
|
|
41
|
+
'/Engine/Transient.BlueprintExtractorSubsystem_0',
|
|
42
|
+
];
|
|
43
|
+
for (const path of candidatePaths) {
|
|
44
|
+
try {
|
|
45
|
+
// Try calling ListAssets as a health check
|
|
46
|
+
const res = await this.rawCall(path, 'ListAssets', { PackagePath: '/Game', bRecursive: false, ClassFilter: '' });
|
|
47
|
+
if (res !== null) {
|
|
48
|
+
this.subsystemPath = path;
|
|
49
|
+
return path;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Try next candidate
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error('BlueprintExtractor subsystem not found. Ensure the plugin is loaded in the editor.');
|
|
57
|
+
}
|
|
58
|
+
async rawCall(objectPath, functionName, parameters) {
|
|
59
|
+
const body = {
|
|
60
|
+
objectPath,
|
|
61
|
+
functionName,
|
|
62
|
+
parameters,
|
|
63
|
+
generateTransaction: false,
|
|
64
|
+
};
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${this.baseUrl}/remote/object/call`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
return null;
|
|
77
|
+
return await res.json();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
clearTimeout(timeout);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async callSubsystem(method, params) {
|
|
85
|
+
const connected = await this.checkConnection();
|
|
86
|
+
if (!connected) {
|
|
87
|
+
throw new Error(`UE Editor not running or Remote Control not available on ${this.host}:${this.port}`);
|
|
88
|
+
}
|
|
89
|
+
const objectPath = await this.discoverSubsystem();
|
|
90
|
+
const res = await this.rawCall(objectPath, method, params);
|
|
91
|
+
if (res === null) {
|
|
92
|
+
throw new Error(`Failed to call ${method} on BlueprintExtractorSubsystem`);
|
|
93
|
+
}
|
|
94
|
+
// The subsystem methods return FString, which comes back as ReturnValue
|
|
95
|
+
const returnValue = res.ReturnValue ?? JSON.stringify(res);
|
|
96
|
+
return typeof returnValue === 'string' ? returnValue : JSON.stringify(returnValue);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "blueprint-extractor-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for UE5 BlueprintExtractor plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"blueprint-extractor-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"zod": "^3.24.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.8.0",
|
|
24
|
+
"@types/node": "^22.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|