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 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 installs the server and writes the correct config block for your MCP client (Claude Desktop, Cursor, or VS Code). The C++ plugin is optional — file system and code generation tools work without it.
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', 'settings.json');
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
- // Only add env if project root is specified
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
- if (client === 'claude-code') {
123
- // Claude Code uses settings.json with mcpServers at top level
124
- if (!config['mcpServers'] || typeof config['mcpServers'] !== 'object') {
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 — Take a high-resolution 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 sendOrDisconnect(_bridge, { type: 'viewport.screenshot', payload });
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 sendOrDisconnect(_bridge, { type: 'viewport.hiresScreenshot', payload });
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-unreal-engine-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server giving AI assistants full access to Unreal Engine 5.7 projects",
5
5
  "type": "module",
6
6
  "engines": {