ultimate-unreal-engine-mcp 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/setup.js +10 -15
- package/dist/tools/viewport/index.js +652 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,10 +36,20 @@ npx ultimate-unreal-engine-mcp setup
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
+
# Claude Desktop (default)
|
|
39
40
|
npx ultimate-unreal-engine-mcp setup
|
|
41
|
+
|
|
42
|
+
# Claude Code CLI
|
|
43
|
+
npx ultimate-unreal-engine-mcp setup --client claude-code
|
|
44
|
+
|
|
45
|
+
# Cursor
|
|
46
|
+
npx ultimate-unreal-engine-mcp setup --client cursor
|
|
47
|
+
|
|
48
|
+
# With a UE project (also installs the C++ plugin)
|
|
49
|
+
npx ultimate-unreal-engine-mcp setup --project "C:/MyGame"
|
|
40
50
|
```
|
|
41
51
|
|
|
42
|
-
This command
|
|
52
|
+
This command writes the correct config for your client. The C++ plugin is optional — file system and code generation tools work without it.
|
|
43
53
|
|
|
44
54
|
---
|
|
45
55
|
|
package/dist/setup.js
CHANGED
|
@@ -25,7 +25,7 @@ function getConfigPath(client) {
|
|
|
25
25
|
}
|
|
26
26
|
return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
27
27
|
case 'claude-code':
|
|
28
|
-
return join(home, '.claude
|
|
28
|
+
return join(home, '.claude.json');
|
|
29
29
|
case 'cursor':
|
|
30
30
|
if (platform === 'win32') {
|
|
31
31
|
return join(env['APPDATA'] || join(home, 'AppData', 'Roaming'), 'Cursor', 'User', 'globalStorage', 'cursor.mcp', 'config.json');
|
|
@@ -108,7 +108,11 @@ function configureClient(client, port, projectRoot) {
|
|
|
108
108
|
command: 'npx',
|
|
109
109
|
args: ['-y', 'ultimate-unreal-engine-mcp'],
|
|
110
110
|
};
|
|
111
|
-
//
|
|
111
|
+
// Claude Code requires "type": "stdio" in each entry
|
|
112
|
+
if (client === 'claude-code') {
|
|
113
|
+
serverEntry['type'] = 'stdio';
|
|
114
|
+
}
|
|
115
|
+
// Only add env if project root is specified or port is non-default
|
|
112
116
|
const envBlock = {};
|
|
113
117
|
if (projectRoot) {
|
|
114
118
|
envBlock['UE_PROJECT_ROOT'] = projectRoot;
|
|
@@ -119,20 +123,11 @@ function configureClient(client, port, projectRoot) {
|
|
|
119
123
|
if (Object.keys(envBlock).length > 0) {
|
|
120
124
|
serverEntry['env'] = envBlock;
|
|
121
125
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
config['mcpServers'] = {};
|
|
126
|
-
}
|
|
127
|
-
config['mcpServers']['unreal-engine'] = serverEntry;
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
// Claude Desktop and Cursor use the standard format
|
|
131
|
-
if (!config['mcpServers'] || typeof config['mcpServers'] !== 'object') {
|
|
132
|
-
config['mcpServers'] = {};
|
|
133
|
-
}
|
|
134
|
-
config['mcpServers']['unreal-engine'] = serverEntry;
|
|
126
|
+
// All clients use mcpServers at top level
|
|
127
|
+
if (!config['mcpServers'] || typeof config['mcpServers'] !== 'object') {
|
|
128
|
+
config['mcpServers'] = {};
|
|
135
129
|
}
|
|
130
|
+
config['mcpServers']['unreal-engine'] = serverEntry;
|
|
136
131
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
137
132
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
138
133
|
log(` Configured ${client} at ${configPath}`);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// src/tools/viewport/index.ts
|
|
2
2
|
// MCP tool implementations for editor state, PIE control, and viewport operations (Phase 12).
|
|
3
|
+
// Extended with base64 image returns and autonomous visual review tools (Phase 31).
|
|
3
4
|
// All tools route commands through PluginBridgeClient to the C++ MCPBridge plugin.
|
|
4
5
|
// Returns structured plugin_not_connected errors when the plugin is absent.
|
|
5
6
|
import { z } from 'zod';
|
|
7
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
6
8
|
import { withKnownIssues } from '../known-issues/middleware.js';
|
|
7
9
|
import { PluginBridgeClient, PluginNotConnectedError } from '../../plugin-bridge/client.js';
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +45,113 @@ async function sendOrDisconnect(bridge, cmd) {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
48
|
+
// readScreenshotAsImage helper
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/**
|
|
51
|
+
* Reads a screenshot file from disk and returns MCP content blocks.
|
|
52
|
+
*
|
|
53
|
+
* - Returns image + text blocks on success (base64-encoded PNG).
|
|
54
|
+
* - Returns a single text warning block if file > 5MB (prevents MCP message bloat).
|
|
55
|
+
* - Returns a single text error block if file does not exist.
|
|
56
|
+
*
|
|
57
|
+
* Per CONTEXT.md non-negotiable: 5MB cap.
|
|
58
|
+
* The file path is always returned in the text block for traceability.
|
|
59
|
+
*/
|
|
60
|
+
async function readScreenshotAsImage(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
const fileInfo = await stat(filePath);
|
|
63
|
+
if (fileInfo.size > 5 * 1024 * 1024) {
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
type: 'text',
|
|
67
|
+
text: JSON.stringify({ warning: 'image_too_large', file_path: filePath, size_bytes: fileInfo.size }),
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
const buffer = await readFile(filePath);
|
|
72
|
+
const base64String = buffer.toString('base64');
|
|
73
|
+
return [
|
|
74
|
+
{ type: 'image', data: base64String, mimeType: 'image/png' },
|
|
75
|
+
{ type: 'text', text: JSON.stringify({ file_path: filePath }) },
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const nodeErr = err;
|
|
80
|
+
if (nodeErr.code === 'ENOENT') {
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
type: 'text',
|
|
84
|
+
text: JSON.stringify({ error: 'screenshot_file_not_found', file_path: filePath }),
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// sendScreenshotCommand helper
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/**
|
|
95
|
+
* Sends a screenshot command to the bridge and reads the resulting image from disk.
|
|
96
|
+
*
|
|
97
|
+
* Flow:
|
|
98
|
+
* 1. Sends the command via bridge.sendCommand.
|
|
99
|
+
* 2. On command failure: returns isError ToolResult.
|
|
100
|
+
* 3. If response.data.file_path exists: waits 200ms for disk flush, reads image.
|
|
101
|
+
* 4. If only response.data.screenshot_dir: falls back to text-only (backward compat).
|
|
102
|
+
* 5. Catches PluginNotConnectedError and returns structured error.
|
|
103
|
+
*/
|
|
104
|
+
async function sendScreenshotCommand(bridge, commandType, payload) {
|
|
105
|
+
try {
|
|
106
|
+
const response = await bridge.sendCommand({ type: commandType, payload, correlationId: '' });
|
|
107
|
+
if (!response.success) {
|
|
108
|
+
return {
|
|
109
|
+
isError: true,
|
|
110
|
+
content: [{ type: 'text', text: JSON.stringify({ error: response.error }) }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const data = response.data ?? {};
|
|
114
|
+
const filePath = data['file_path'];
|
|
115
|
+
if (filePath) {
|
|
116
|
+
// Wait for screenshot to flush to disk
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
118
|
+
const imageBlocks = await readScreenshotAsImage(filePath);
|
|
119
|
+
return { content: imageBlocks };
|
|
120
|
+
}
|
|
121
|
+
// Fallback: no file_path in response (e.g., screenshot_dir only)
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err instanceof PluginNotConnectedError) {
|
|
128
|
+
return {
|
|
129
|
+
isError: true,
|
|
130
|
+
content: [{ type: 'text', text: JSON.stringify(err.bridgeError) }],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Shared angle union schema
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
const anglePresets = ['front', 'back', 'left', 'right', 'top', '45deg'];
|
|
140
|
+
const angleSchema = z
|
|
141
|
+
.union([
|
|
142
|
+
z.enum(anglePresets),
|
|
143
|
+
z.object({ yaw: z.number(), pitch: z.number() }),
|
|
144
|
+
])
|
|
145
|
+
.optional()
|
|
146
|
+
.default('front');
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Shared target union schema
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const targetSchema = z.union([
|
|
151
|
+
z.string().describe('Actor label (resolved server-side)'),
|
|
152
|
+
z.object({ x: z.number(), y: z.number(), z: z.number() }).describe('World position in UE units (cm)'),
|
|
153
|
+
]);
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
46
155
|
// registerViewportTools
|
|
47
156
|
// ---------------------------------------------------------------------------
|
|
48
157
|
/**
|
|
@@ -52,16 +161,23 @@ async function sendOrDisconnect(bridge, cmd) {
|
|
|
52
161
|
* When the plugin is not connected, each handler returns:
|
|
53
162
|
* { isError: true, content: [{ type: 'text', text: <plugin_not_connected JSON> }] }
|
|
54
163
|
*
|
|
55
|
-
* Tools registered (Phase 12):
|
|
164
|
+
* Tools registered (Phase 12 + Phase 31):
|
|
56
165
|
* ue_editor_state — Query selected actors, open assets, viewport camera
|
|
57
166
|
* ue_pie_start — Start a PIE session
|
|
58
167
|
* ue_pie_stop — Stop the active PIE session
|
|
59
168
|
* ue_pie_logs — Read PIE output log lines
|
|
60
169
|
* ue_pie_game_state — Query runtime game state during PIE
|
|
61
|
-
* ue_viewport_screenshot — Capture viewport as screenshot
|
|
170
|
+
* ue_viewport_screenshot — Capture viewport as screenshot (now returns inline image)
|
|
62
171
|
* ue_viewport_camera — Control viewport camera position/rotation/FOV
|
|
63
172
|
* ue_viewport_render_mode — Switch viewport render mode
|
|
64
|
-
* ue_viewport_hires_screenshot —
|
|
173
|
+
* ue_viewport_hires_screenshot — High-resolution screenshot (now returns inline image)
|
|
174
|
+
* ue_visual_review — Quick screenshot for iterative visual review loops
|
|
175
|
+
* ue_look_at — Navigate camera to actor or position, optionally screenshot
|
|
176
|
+
* ue_orbit_review — Multi-angle screenshots orbiting a target
|
|
177
|
+
* ue_iterate_scene — Full visual + semantic context in one call
|
|
178
|
+
* ue_fly_through — Camera waypoint walkthrough with screenshots
|
|
179
|
+
* ue_focus_actor — Auto-frame an actor by label
|
|
180
|
+
* ue_cleanup_screenshots — Delete MCP-generated screenshots from disk
|
|
65
181
|
*
|
|
66
182
|
* @param server The McpServer instance to register tools on.
|
|
67
183
|
* @param bridge Optional PluginBridgeClient for testing (defaults to a new instance).
|
|
@@ -160,7 +276,7 @@ export function registerViewportTools(server, bridge) {
|
|
|
160
276
|
// --------------------------------------------------------------------------
|
|
161
277
|
server.registerTool('ue_viewport_screenshot', {
|
|
162
278
|
title: 'Take Viewport Screenshot',
|
|
163
|
-
description: '[requires_plugin] Capture the active UE viewport as a screenshot. Returns the screenshot directory path.',
|
|
279
|
+
description: '[requires_plugin] Capture the active UE viewport as a screenshot. Returns the screenshot as an inline image (base64) when the plugin saves to a known path, or the screenshot directory path as fallback.',
|
|
164
280
|
inputSchema: z.object({
|
|
165
281
|
width: z
|
|
166
282
|
.number()
|
|
@@ -187,7 +303,7 @@ export function registerViewportTools(server, bridge) {
|
|
|
187
303
|
payload['width'] = args.width;
|
|
188
304
|
if (args.height !== undefined)
|
|
189
305
|
payload['height'] = args.height;
|
|
190
|
-
return
|
|
306
|
+
return sendScreenshotCommand(_bridge, 'viewport.screenshot', payload);
|
|
191
307
|
}));
|
|
192
308
|
// --------------------------------------------------------------------------
|
|
193
309
|
// ue_viewport_camera
|
|
@@ -269,7 +385,7 @@ export function registerViewportTools(server, bridge) {
|
|
|
269
385
|
// --------------------------------------------------------------------------
|
|
270
386
|
server.registerTool('ue_viewport_hires_screenshot', {
|
|
271
387
|
title: 'High-Resolution Viewport Screenshot',
|
|
272
|
-
description: '[requires_plugin] Take a high-resolution screenshot with configurable multiplier. Returns the screenshot directory path.',
|
|
388
|
+
description: '[requires_plugin] Take a high-resolution screenshot with configurable multiplier. Returns the screenshot as an inline image (base64) when the plugin saves to a known path, or the screenshot directory path as fallback.',
|
|
273
389
|
inputSchema: z.object({
|
|
274
390
|
resolution_multiplier: z
|
|
275
391
|
.number()
|
|
@@ -305,6 +421,535 @@ export function registerViewportTools(server, bridge) {
|
|
|
305
421
|
payload['width'] = args.width;
|
|
306
422
|
if (args.height !== undefined)
|
|
307
423
|
payload['height'] = args.height;
|
|
308
|
-
return
|
|
424
|
+
return sendScreenshotCommand(_bridge, 'viewport.hiresScreenshot', payload);
|
|
425
|
+
}));
|
|
426
|
+
// --------------------------------------------------------------------------
|
|
427
|
+
// ue_visual_review (VIS-02)
|
|
428
|
+
// --------------------------------------------------------------------------
|
|
429
|
+
server.registerTool('ue_visual_review', {
|
|
430
|
+
title: 'Visual Review Screenshot',
|
|
431
|
+
description: '[requires_plugin] Take a quick viewport screenshot for visual review. Returns the image inline with camera context. Use this for iterative review loops.',
|
|
432
|
+
inputSchema: z.object({
|
|
433
|
+
width: z
|
|
434
|
+
.number()
|
|
435
|
+
.int()
|
|
436
|
+
.min(64)
|
|
437
|
+
.max(7680)
|
|
438
|
+
.optional()
|
|
439
|
+
.default(1280)
|
|
440
|
+
.describe('Screenshot width in pixels (default 1280)'),
|
|
441
|
+
height: z
|
|
442
|
+
.number()
|
|
443
|
+
.int()
|
|
444
|
+
.min(64)
|
|
445
|
+
.max(4320)
|
|
446
|
+
.optional()
|
|
447
|
+
.default(720)
|
|
448
|
+
.describe('Screenshot height in pixels (default 720)'),
|
|
449
|
+
focus: z
|
|
450
|
+
.string()
|
|
451
|
+
.optional()
|
|
452
|
+
.describe('Describe what to look for in the screenshot (e.g., "check the lighting on the left wall")'),
|
|
453
|
+
}),
|
|
454
|
+
annotations: {
|
|
455
|
+
readOnlyHint: true,
|
|
456
|
+
destructiveHint: false,
|
|
457
|
+
},
|
|
458
|
+
}, withKnownIssues('ue_visual_review', async (args) => {
|
|
459
|
+
const result = await sendScreenshotCommand(_bridge, 'viewport.screenshot', {
|
|
460
|
+
width: args.width,
|
|
461
|
+
height: args.height,
|
|
462
|
+
});
|
|
463
|
+
if (args.focus && result.content) {
|
|
464
|
+
return {
|
|
465
|
+
...result,
|
|
466
|
+
content: [
|
|
467
|
+
{ type: 'text', text: JSON.stringify({ focus_description: args.focus }) },
|
|
468
|
+
...result.content,
|
|
469
|
+
],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}));
|
|
474
|
+
// --------------------------------------------------------------------------
|
|
475
|
+
// ue_look_at (VIS-03)
|
|
476
|
+
// --------------------------------------------------------------------------
|
|
477
|
+
server.registerTool('ue_look_at', {
|
|
478
|
+
title: 'Navigate Camera to Target',
|
|
479
|
+
description: "[requires_plugin] Navigate the editor camera to look at an actor (by label) or world position. Automatically frames the target and optionally takes a screenshot. Claude's primary navigation tool — use actor labels from previous responses.",
|
|
480
|
+
inputSchema: z.object({
|
|
481
|
+
target: targetSchema.describe('Actor label (string) or world position ({x,y,z}) to navigate to'),
|
|
482
|
+
distance: z
|
|
483
|
+
.number()
|
|
484
|
+
.optional()
|
|
485
|
+
.describe('Camera distance from target in UE units (auto-calculated from actor bounds if omitted)'),
|
|
486
|
+
angle: angleSchema.describe('Viewing angle preset ("front","back","left","right","top","45deg") or {yaw,pitch} object'),
|
|
487
|
+
screenshot: z
|
|
488
|
+
.boolean()
|
|
489
|
+
.optional()
|
|
490
|
+
.default(true)
|
|
491
|
+
.describe('Take a screenshot after navigating (default true)'),
|
|
492
|
+
width: z
|
|
493
|
+
.number()
|
|
494
|
+
.int()
|
|
495
|
+
.min(64)
|
|
496
|
+
.max(7680)
|
|
497
|
+
.optional()
|
|
498
|
+
.default(1280)
|
|
499
|
+
.describe('Screenshot width in pixels (default 1280)'),
|
|
500
|
+
height: z
|
|
501
|
+
.number()
|
|
502
|
+
.int()
|
|
503
|
+
.min(64)
|
|
504
|
+
.max(4320)
|
|
505
|
+
.optional()
|
|
506
|
+
.default(720)
|
|
507
|
+
.describe('Screenshot height in pixels (default 720)'),
|
|
508
|
+
}),
|
|
509
|
+
annotations: {
|
|
510
|
+
readOnlyHint: false,
|
|
511
|
+
destructiveHint: false,
|
|
512
|
+
},
|
|
513
|
+
}, withKnownIssues('ue_look_at', async (args) => {
|
|
514
|
+
try {
|
|
515
|
+
const payload = {};
|
|
516
|
+
if (typeof args.target === 'string') {
|
|
517
|
+
payload['target_label'] = args.target;
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
payload['target_position'] = args.target;
|
|
521
|
+
}
|
|
522
|
+
if (args.distance !== undefined)
|
|
523
|
+
payload['distance'] = args.distance;
|
|
524
|
+
if (args.angle !== undefined) {
|
|
525
|
+
if (typeof args.angle === 'string') {
|
|
526
|
+
payload['angle_preset'] = args.angle;
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
payload['angle_custom'] = args.angle;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
payload['screenshot'] = args.screenshot;
|
|
533
|
+
payload['width'] = args.width;
|
|
534
|
+
payload['height'] = args.height;
|
|
535
|
+
const response = await _bridge.sendCommand({ type: 'viewport.lookAt', payload, correlationId: '' });
|
|
536
|
+
if (!response.success) {
|
|
537
|
+
return {
|
|
538
|
+
isError: true,
|
|
539
|
+
content: [{ type: 'text', text: JSON.stringify({ error: response.error }) }],
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const data = response.data ?? {};
|
|
543
|
+
const filePath = data['file_path'];
|
|
544
|
+
if (filePath) {
|
|
545
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
546
|
+
const imageBlocks = await readScreenshotAsImage(filePath);
|
|
547
|
+
return {
|
|
548
|
+
content: [
|
|
549
|
+
...imageBlocks,
|
|
550
|
+
{ type: 'text', text: JSON.stringify(data) },
|
|
551
|
+
],
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
if (err instanceof PluginNotConnectedError) {
|
|
558
|
+
return {
|
|
559
|
+
isError: true,
|
|
560
|
+
content: [{ type: 'text', text: JSON.stringify(err.bridgeError) }],
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
}));
|
|
566
|
+
// --------------------------------------------------------------------------
|
|
567
|
+
// ue_orbit_review (VIS-04)
|
|
568
|
+
// --------------------------------------------------------------------------
|
|
569
|
+
server.registerTool('ue_orbit_review', {
|
|
570
|
+
title: 'Orbit Multi-Angle Review',
|
|
571
|
+
description: '[requires_plugin] Take screenshots from multiple angles orbiting around a target actor or position. Returns one image per angle for multi-angle inspection.',
|
|
572
|
+
inputSchema: z.object({
|
|
573
|
+
target: targetSchema.describe('Actor label (string) or world position ({x,y,z}) to orbit around'),
|
|
574
|
+
distance: z
|
|
575
|
+
.number()
|
|
576
|
+
.optional()
|
|
577
|
+
.describe('Camera distance from target in UE units (auto-calculated from actor bounds if omitted)'),
|
|
578
|
+
angles: z
|
|
579
|
+
.array(z.number())
|
|
580
|
+
.max(12)
|
|
581
|
+
.optional()
|
|
582
|
+
.default([0, 90, 180, 270])
|
|
583
|
+
.describe('Yaw angles in degrees to capture (default [0,90,180,270], max 12 entries)'),
|
|
584
|
+
pitch: z
|
|
585
|
+
.number()
|
|
586
|
+
.optional()
|
|
587
|
+
.default(-20)
|
|
588
|
+
.describe('Camera pitch angle in degrees (default -20)'),
|
|
589
|
+
width: z
|
|
590
|
+
.number()
|
|
591
|
+
.int()
|
|
592
|
+
.optional()
|
|
593
|
+
.default(1280)
|
|
594
|
+
.describe('Screenshot width in pixels (default 1280)'),
|
|
595
|
+
height: z
|
|
596
|
+
.number()
|
|
597
|
+
.int()
|
|
598
|
+
.optional()
|
|
599
|
+
.default(720)
|
|
600
|
+
.describe('Screenshot height in pixels (default 720)'),
|
|
601
|
+
}),
|
|
602
|
+
annotations: {
|
|
603
|
+
readOnlyHint: true,
|
|
604
|
+
destructiveHint: false,
|
|
605
|
+
},
|
|
606
|
+
}, withKnownIssues('ue_orbit_review', async (args) => {
|
|
607
|
+
try {
|
|
608
|
+
const content = [];
|
|
609
|
+
for (const yaw of args.angles) {
|
|
610
|
+
const payload = {
|
|
611
|
+
screenshot: true,
|
|
612
|
+
width: args.width,
|
|
613
|
+
height: args.height,
|
|
614
|
+
angle: { yaw, pitch: args.pitch },
|
|
615
|
+
};
|
|
616
|
+
if (typeof args.target === 'string') {
|
|
617
|
+
payload['target_label'] = args.target;
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
payload['target_position'] = args.target;
|
|
621
|
+
}
|
|
622
|
+
if (args.distance !== undefined)
|
|
623
|
+
payload['distance'] = args.distance;
|
|
624
|
+
try {
|
|
625
|
+
const response = await _bridge.sendCommand({ type: 'viewport.lookAt', payload, correlationId: '' });
|
|
626
|
+
if (!response.success) {
|
|
627
|
+
content.push({ type: 'text', text: JSON.stringify({ angle_yaw: yaw, error: response.error }) });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const data = response.data ?? {};
|
|
631
|
+
const filePath = data['file_path'];
|
|
632
|
+
content.push({ type: 'text', text: JSON.stringify({ angle_yaw: yaw, pitch: args.pitch }) });
|
|
633
|
+
if (filePath) {
|
|
634
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
635
|
+
const imageBlocks = await readScreenshotAsImage(filePath);
|
|
636
|
+
content.push(...imageBlocks);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
content.push({ type: 'text', text: JSON.stringify({ angle_yaw: yaw, result: data }) });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch (innerErr) {
|
|
643
|
+
if (innerErr instanceof PluginNotConnectedError)
|
|
644
|
+
throw innerErr;
|
|
645
|
+
content.push({ type: 'text', text: JSON.stringify({ angle_yaw: yaw, error: String(innerErr) }) });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return { content };
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
if (err instanceof PluginNotConnectedError) {
|
|
652
|
+
return {
|
|
653
|
+
isError: true,
|
|
654
|
+
content: [{ type: 'text', text: JSON.stringify(err.bridgeError) }],
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
throw err;
|
|
658
|
+
}
|
|
659
|
+
}));
|
|
660
|
+
// --------------------------------------------------------------------------
|
|
661
|
+
// ue_iterate_scene (VIS-05)
|
|
662
|
+
// --------------------------------------------------------------------------
|
|
663
|
+
server.registerTool('ue_iterate_scene', {
|
|
664
|
+
title: 'Full Scene Context Snapshot',
|
|
665
|
+
description: '[requires_plugin] Get full visual and semantic context in one call. Takes a screenshot AND returns structured data about nearby actors. Use this to orient yourself — see what you are looking at and what is nearby.',
|
|
666
|
+
inputSchema: z.object({
|
|
667
|
+
width: z
|
|
668
|
+
.number()
|
|
669
|
+
.int()
|
|
670
|
+
.optional()
|
|
671
|
+
.default(1280)
|
|
672
|
+
.describe('Screenshot width in pixels (default 1280)'),
|
|
673
|
+
height: z
|
|
674
|
+
.number()
|
|
675
|
+
.int()
|
|
676
|
+
.optional()
|
|
677
|
+
.default(720)
|
|
678
|
+
.describe('Screenshot height in pixels (default 720)'),
|
|
679
|
+
include_actors: z
|
|
680
|
+
.boolean()
|
|
681
|
+
.optional()
|
|
682
|
+
.default(true)
|
|
683
|
+
.describe('Include actors from the view frustum in the response (default true)'),
|
|
684
|
+
include_materials: z
|
|
685
|
+
.boolean()
|
|
686
|
+
.optional()
|
|
687
|
+
.default(false)
|
|
688
|
+
.describe('Include material information (default false)'),
|
|
689
|
+
radius: z
|
|
690
|
+
.number()
|
|
691
|
+
.optional()
|
|
692
|
+
.describe('Only include actors within N UE units of camera (no limit if omitted)'),
|
|
693
|
+
}),
|
|
694
|
+
annotations: {
|
|
695
|
+
readOnlyHint: true,
|
|
696
|
+
destructiveHint: false,
|
|
697
|
+
},
|
|
698
|
+
}, withKnownIssues('ue_iterate_scene', async (args) => {
|
|
699
|
+
try {
|
|
700
|
+
// Step 1: take a screenshot
|
|
701
|
+
const screenshotResponse = await _bridge.sendCommand({
|
|
702
|
+
type: 'viewport.screenshot',
|
|
703
|
+
payload: { width: args.width, height: args.height },
|
|
704
|
+
correlationId: '',
|
|
705
|
+
});
|
|
706
|
+
let imageBlocks = [];
|
|
707
|
+
let screenshotMeta = {};
|
|
708
|
+
if (screenshotResponse.success) {
|
|
709
|
+
const ssData = screenshotResponse.data ?? {};
|
|
710
|
+
const filePath = ssData['file_path'];
|
|
711
|
+
if (filePath) {
|
|
712
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
713
|
+
imageBlocks = await readScreenshotAsImage(filePath);
|
|
714
|
+
}
|
|
715
|
+
screenshotMeta = ssData;
|
|
716
|
+
}
|
|
717
|
+
// Step 2: get actors in frustum
|
|
718
|
+
let actorsData = {};
|
|
719
|
+
if (args.include_actors) {
|
|
720
|
+
const actorPayload = { max_actors: 50 };
|
|
721
|
+
if (args.radius !== undefined)
|
|
722
|
+
actorPayload['radius'] = args.radius;
|
|
723
|
+
const actorResponse = await _bridge.sendCommand({
|
|
724
|
+
type: 'viewport.frustumActors',
|
|
725
|
+
payload: actorPayload,
|
|
726
|
+
correlationId: '',
|
|
727
|
+
});
|
|
728
|
+
if (actorResponse.success) {
|
|
729
|
+
actorsData = actorResponse.data ?? {};
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// Step 3: combine results
|
|
733
|
+
const sceneContext = {
|
|
734
|
+
screenshot: screenshotMeta,
|
|
735
|
+
actors: actorsData,
|
|
736
|
+
include_materials: args.include_materials,
|
|
737
|
+
};
|
|
738
|
+
return {
|
|
739
|
+
content: [
|
|
740
|
+
...imageBlocks,
|
|
741
|
+
{ type: 'text', text: JSON.stringify(sceneContext) },
|
|
742
|
+
],
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
if (err instanceof PluginNotConnectedError) {
|
|
747
|
+
return {
|
|
748
|
+
isError: true,
|
|
749
|
+
content: [{ type: 'text', text: JSON.stringify(err.bridgeError) }],
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
throw err;
|
|
753
|
+
}
|
|
754
|
+
}));
|
|
755
|
+
// --------------------------------------------------------------------------
|
|
756
|
+
// ue_fly_through (VIS-06)
|
|
757
|
+
// --------------------------------------------------------------------------
|
|
758
|
+
server.registerTool('ue_fly_through', {
|
|
759
|
+
title: 'Camera Fly-Through Waypoints',
|
|
760
|
+
description: '[requires_plugin] Move the camera through a series of waypoints, taking a screenshot at each. Use this for level walkthroughs and spatial surveys.',
|
|
761
|
+
inputSchema: z.object({
|
|
762
|
+
waypoints: z
|
|
763
|
+
.array(z.object({
|
|
764
|
+
location: z.object({ x: z.number(), y: z.number(), z: z.number() }),
|
|
765
|
+
look_at: z.object({ x: z.number(), y: z.number(), z: z.number() }),
|
|
766
|
+
label: z.string().optional().describe('Optional label for this waypoint'),
|
|
767
|
+
}))
|
|
768
|
+
.min(1)
|
|
769
|
+
.max(20)
|
|
770
|
+
.describe('Camera waypoints to visit (1–20 waypoints)'),
|
|
771
|
+
width: z
|
|
772
|
+
.number()
|
|
773
|
+
.int()
|
|
774
|
+
.optional()
|
|
775
|
+
.default(1280)
|
|
776
|
+
.describe('Screenshot width in pixels (default 1280)'),
|
|
777
|
+
height: z
|
|
778
|
+
.number()
|
|
779
|
+
.int()
|
|
780
|
+
.optional()
|
|
781
|
+
.default(720)
|
|
782
|
+
.describe('Screenshot height in pixels (default 720)'),
|
|
783
|
+
}),
|
|
784
|
+
annotations: {
|
|
785
|
+
readOnlyHint: true,
|
|
786
|
+
destructiveHint: false,
|
|
787
|
+
},
|
|
788
|
+
}, withKnownIssues('ue_fly_through', async (args) => {
|
|
789
|
+
try {
|
|
790
|
+
const content = [];
|
|
791
|
+
for (let i = 0; i < args.waypoints.length; i++) {
|
|
792
|
+
const wp = args.waypoints[i];
|
|
793
|
+
const waypointLabel = wp.label ?? `Waypoint ${i + 1}`;
|
|
794
|
+
content.push({ type: 'text', text: JSON.stringify({ waypoint: waypointLabel, index: i + 1 }) });
|
|
795
|
+
try {
|
|
796
|
+
// Move camera to waypoint
|
|
797
|
+
await _bridge.sendCommand({
|
|
798
|
+
type: 'viewport.camera',
|
|
799
|
+
payload: { location: wp.location, look_at: wp.look_at },
|
|
800
|
+
correlationId: '',
|
|
801
|
+
});
|
|
802
|
+
// Take screenshot
|
|
803
|
+
const screenshotResponse = await _bridge.sendCommand({
|
|
804
|
+
type: 'viewport.screenshot',
|
|
805
|
+
payload: { width: args.width, height: args.height },
|
|
806
|
+
correlationId: '',
|
|
807
|
+
});
|
|
808
|
+
if (screenshotResponse.success) {
|
|
809
|
+
const ssData = screenshotResponse.data ?? {};
|
|
810
|
+
const filePath = ssData['file_path'];
|
|
811
|
+
if (filePath) {
|
|
812
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
813
|
+
const imageBlocks = await readScreenshotAsImage(filePath);
|
|
814
|
+
content.push(...imageBlocks);
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
content.push({ type: 'text', text: JSON.stringify({ waypoint: waypointLabel, result: ssData }) });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
content.push({ type: 'text', text: JSON.stringify({ waypoint: waypointLabel, error: screenshotResponse.error }) });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
catch (innerErr) {
|
|
825
|
+
if (innerErr instanceof PluginNotConnectedError)
|
|
826
|
+
throw innerErr;
|
|
827
|
+
content.push({ type: 'text', text: JSON.stringify({ waypoint: waypointLabel, error: String(innerErr) }) });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return { content };
|
|
831
|
+
}
|
|
832
|
+
catch (err) {
|
|
833
|
+
if (err instanceof PluginNotConnectedError) {
|
|
834
|
+
return {
|
|
835
|
+
isError: true,
|
|
836
|
+
content: [{ type: 'text', text: JSON.stringify(err.bridgeError) }],
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
throw err;
|
|
840
|
+
}
|
|
841
|
+
}));
|
|
842
|
+
// --------------------------------------------------------------------------
|
|
843
|
+
// ue_focus_actor (VIS-07)
|
|
844
|
+
// --------------------------------------------------------------------------
|
|
845
|
+
server.registerTool('ue_focus_actor', {
|
|
846
|
+
title: 'Frame Actor in Viewport',
|
|
847
|
+
description: '[requires_plugin] Frame a specific actor perfectly in the viewport. Auto-calculates camera distance from actor bounds. Use when you need a detailed view of a specific object.',
|
|
848
|
+
inputSchema: z.object({
|
|
849
|
+
actor_label: z
|
|
850
|
+
.string()
|
|
851
|
+
.describe('Label of the actor to focus on'),
|
|
852
|
+
padding: z
|
|
853
|
+
.number()
|
|
854
|
+
.optional()
|
|
855
|
+
.default(1.5)
|
|
856
|
+
.describe('Multiplier on actor bounds for camera distance (default 1.5)'),
|
|
857
|
+
angle: angleSchema.describe('Viewing angle preset ("front","back","left","right","top","45deg") or {yaw,pitch} object (default "front")'),
|
|
858
|
+
screenshot: z
|
|
859
|
+
.boolean()
|
|
860
|
+
.optional()
|
|
861
|
+
.default(true)
|
|
862
|
+
.describe('Take a screenshot after framing (default true)'),
|
|
863
|
+
width: z
|
|
864
|
+
.number()
|
|
865
|
+
.int()
|
|
866
|
+
.optional()
|
|
867
|
+
.default(1280)
|
|
868
|
+
.describe('Screenshot width in pixels (default 1280)'),
|
|
869
|
+
height: z
|
|
870
|
+
.number()
|
|
871
|
+
.int()
|
|
872
|
+
.optional()
|
|
873
|
+
.default(720)
|
|
874
|
+
.describe('Screenshot height in pixels (default 720)'),
|
|
875
|
+
}),
|
|
876
|
+
annotations: {
|
|
877
|
+
readOnlyHint: false,
|
|
878
|
+
destructiveHint: false,
|
|
879
|
+
},
|
|
880
|
+
}, withKnownIssues('ue_focus_actor', async (args) => {
|
|
881
|
+
try {
|
|
882
|
+
const payload = {
|
|
883
|
+
actor_label: args.actor_label,
|
|
884
|
+
padding: args.padding,
|
|
885
|
+
screenshot: args.screenshot,
|
|
886
|
+
width: args.width,
|
|
887
|
+
height: args.height,
|
|
888
|
+
};
|
|
889
|
+
if (args.angle !== undefined) {
|
|
890
|
+
if (typeof args.angle === 'string') {
|
|
891
|
+
payload['angle_preset'] = args.angle;
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
payload['angle_custom'] = args.angle;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const response = await _bridge.sendCommand({ type: 'viewport.focusActor', payload, correlationId: '' });
|
|
898
|
+
if (!response.success) {
|
|
899
|
+
return {
|
|
900
|
+
isError: true,
|
|
901
|
+
content: [{ type: 'text', text: JSON.stringify({ error: response.error }) }],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const data = response.data ?? {};
|
|
905
|
+
const filePath = data['file_path'];
|
|
906
|
+
if (filePath) {
|
|
907
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
908
|
+
const imageBlocks = await readScreenshotAsImage(filePath);
|
|
909
|
+
return {
|
|
910
|
+
content: [
|
|
911
|
+
...imageBlocks,
|
|
912
|
+
{ type: 'text', text: JSON.stringify(data) },
|
|
913
|
+
],
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
if (err instanceof PluginNotConnectedError) {
|
|
920
|
+
return {
|
|
921
|
+
isError: true,
|
|
922
|
+
content: [{ type: 'text', text: JSON.stringify(err.bridgeError) }],
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
throw err;
|
|
926
|
+
}
|
|
927
|
+
}));
|
|
928
|
+
// --------------------------------------------------------------------------
|
|
929
|
+
// ue_cleanup_screenshots (VIS-08)
|
|
930
|
+
// --------------------------------------------------------------------------
|
|
931
|
+
server.registerTool('ue_cleanup_screenshots', {
|
|
932
|
+
title: 'Clean Up MCP Screenshots',
|
|
933
|
+
description: '[requires_plugin] Delete MCP-generated screenshots to free disk space. Only targets files with the mcp_screenshot_ prefix — never deletes user screenshots. Call this when a visual review loop is complete.',
|
|
934
|
+
inputSchema: z.object({
|
|
935
|
+
confirm: z
|
|
936
|
+
.literal(true)
|
|
937
|
+
.describe('Must be true — safety guard to prevent accidental deletion'),
|
|
938
|
+
keep_last: z
|
|
939
|
+
.number()
|
|
940
|
+
.int()
|
|
941
|
+
.optional()
|
|
942
|
+
.default(0)
|
|
943
|
+
.describe('Preserve the N most recent screenshots (default 0 = delete all)'),
|
|
944
|
+
}),
|
|
945
|
+
annotations: {
|
|
946
|
+
readOnlyHint: false,
|
|
947
|
+
destructiveHint: true,
|
|
948
|
+
},
|
|
949
|
+
}, withKnownIssues('ue_cleanup_screenshots', async (args) => {
|
|
950
|
+
return sendOrDisconnect(_bridge, {
|
|
951
|
+
type: 'viewport.cleanupScreenshots',
|
|
952
|
+
payload: { keep_last: args.keep_last },
|
|
953
|
+
});
|
|
309
954
|
}));
|
|
310
955
|
}
|