unreal-engine-mcp-server 0.6.0 → 0.6.1-beta-a
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/CHANGELOG.md +50 -1
- package/README.md +15 -9
- package/dist/cli.js +15 -2
- package/dist/proxy/doctor.js +94 -0
- package/dist/proxy/init.js +88 -0
- package/dist/proxy/native-proxy.js +11 -2
- package/dist/utils/logging/logger.js +7 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,55 @@ _Nothing yet._
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
+
## 🏷️ [0.6.1-beta-a] - 2026-11-06
|
|
17
|
+
|
|
18
|
+
> [!NOTE]
|
|
19
|
+
> **Beta.** Published as a semver prerelease (`0.6.1-beta-a`) under the npm `beta` dist-tag, so `npm install` keeps serving the newest stable release, **`0.6.0`**. This section is everything on `dev` since the `v0.6.0` tag, written from the code diff.
|
|
20
|
+
|
|
21
|
+
<details>
|
|
22
|
+
<summary><b>✨ Added</b></summary>
|
|
23
|
+
|
|
24
|
+
- **Engine versions**: a capability an older engine cannot run reads `unavailable` and is refused `ENGINE_VERSION_UNSUPPORTED` (Geometry Script 5.1, MetaSound graph reads and skinning 5.5, retargeting 5.8).
|
|
25
|
+
- **Search and execute**: a typo is read as the word meant (`corrected`), `options.aggregate` counts a long list by group, and an alias sent as tool + action resolves.
|
|
26
|
+
- **Blueprints**: `lint_blueprint` follows values (a divisor that can be 0, an index past the end, a Set outside a Value Range, `==` on computed floats, a decided Branch, an endless While); `fuzz_blueprint` calls a Blueprint's functions in Play with edge-case inputs and names each error's node; MVVM viewmodels (UE 5.4+); `add_variable` `exposeToCinematics`.
|
|
27
|
+
- **Graph as code**: `read_graph_as_code` `entry` and `codeOnly`; `write_graph_from_code` reads back only what it wrote.
|
|
28
|
+
- **Views and cameras**: `camera` looks through a camera actor (`screenshot`, `describe_view`, `analyze_view`, `audit_materials`); `pause` and `list_track_keys` name each cut's camera; `screenshot` `renderSize` and `pathTraced` (a denoised path-traced image); `fov` on every view read.
|
|
29
|
+
- **Plugins and project**: `get_plugin_info`, `create_plugin` / `remove_plugin`, game features (UE 5.1+), data registries, an unused-asset report, `set_project_thumbnail`, `get_console_variables`, `remove_cpp_class`.
|
|
30
|
+
- **Editor**: `read_editor_ui`, `configure_editor` `close_tab`, `set_selected_actors`, viewport `show_flags`, `reimport` without a dialog, `set_current_level`, `console_command` `filter`.
|
|
31
|
+
- **Actors**: snap to grid, distribute, align, lock, group, randomize, replace, convert to a Blueprint, move to a level, numbered rename, copies along a spline, `place_decals`; `list` `bounds` and `find` locations; `sample_motion` `feel` (jump, run, key latency, camera jerk).
|
|
32
|
+
- **Sequences**: `add_actors` `rebind` points a sequence copied with its level at the new level's actors; `add_behaviour` `sequence_trigger` plays one when the player walks in.
|
|
33
|
+
- **Geometry**: `append_tubes`, `append_polygons` `repeat` and `bake_vertex_colors` `coarseShare`.
|
|
34
|
+
- **Look and sound**: material `color` / `glow`, light `flicker`, a first-person view, `sound_trigger`, subtitles, sound volume and looping, footsteps, animation play rate, `massKg`.
|
|
35
|
+
- **Scripts and sheets**: `run_script` takes `(if cond a b)` as a value and waits within `deadlineMs` for a call that answers later; contact and thumbnail sheets take `keepFile: false`.
|
|
36
|
+
- **Frame budget**: `fit_frame_budget` finds which lights' shadows and far small meshes to turn down to meet a GPU frame time at the least change to the picture, and keeps them as one undo step.
|
|
37
|
+
- **Proxy CLI**: `init <client>` writes a client's MCP config; `doctor` checks the setup (the token is never shown).
|
|
38
|
+
|
|
39
|
+
</details>
|
|
40
|
+
|
|
41
|
+
<details>
|
|
42
|
+
<summary><b>🔧 Fixed</b></summary>
|
|
43
|
+
|
|
44
|
+
- **Security**: hardening across the transports (native `/mcp` never binds off loopback; a LAN WebSocket bind needs the token and TLS), the path model (a path-restricted token's prefixes hold in every save, delete, search and listing), scopes and request inputs; a consent grant comes back only when nothing ran; the proxy sends its token only to a loopback upstream.
|
|
45
|
+
- **Plugin loading**: Movie Render Queue, Takes and MetaSound are required plugin references (left off in a Blueprint-only project, EngineWire did not load on Windows and the editor crashed at startup on macOS); geometry and skinning actions refuse with `GEOMETRYSCRIPTING_PLUGIN_NOT_ENABLED` while GeometryScripting is off instead of crashing; the game features module compiles on UE 5.6 and 5.7.
|
|
46
|
+
- **Crashes**: a glTF or GLB export and a key tapped with no Play session no longer take the editor down.
|
|
47
|
+
- **Search**: about 250 more plain requests find their capability, and an alias a family member declared runs that member.
|
|
48
|
+
- **Replies**: long lists page and aggregate from the reply's `data`; console replies stay short; a script step still running at the deadline says so; write replies pass on only lint errors; logpoints read an unwired input's own value; the tool says how to get a fresh consent grant.
|
|
49
|
+
- **Input**: simulated game keys stay out of the editor, and `sample_motion` inputs need the write scope.
|
|
50
|
+
- **Undo**: asset edits are one step each, an edit that changes nothing is refused instead of answered done, enum undo no longer crashes UE 5.7+, and a script's rollback undoes only its own steps.
|
|
51
|
+
- **Graph as code**: `%`, `==` on text, calls used as values, ids with underscores, custom event calls, other classes' variables, expose-on-spawn inputs, calls on the Blueprint itself and events bound to a delegate write as `read_graph_as_code` prints them.
|
|
52
|
+
- **Placement**: props in a room built as one mesh read as they are; a support with no collision is named; `fix_coplanar` checks each move against the others.
|
|
53
|
+
- **Views**: frame readers warn while shaders, meshes or textures still build; `contact_sheet` draws beside water and titles small sheets in full.
|
|
54
|
+
- **Geometry**: `bevel` and `chamfer` cut whole parts, and material and PolyGroup edits are their own undo steps.
|
|
55
|
+
- **Assets**: `check_mesh` finds parts wound inside out, on skeletal meshes too; FBX imports open no Message Log window and convert units with options; material instances draw their parent; morph targets and skin weight profiles survive rebuilds; output files reach captures, exports and thumbnails.
|
|
56
|
+
- **Editor state**: edits are refused during a Live Coding compile; `save_all` saves streaming sublevels; dry runs no longer compile or save; Blueprint compiles leave placed skeletal meshes posed; Behavior Trees build from their Root.
|
|
57
|
+
- **Niagara**: a created emitter keeps the name it was asked for.
|
|
58
|
+
- **Game framework and materials**: `default_pawn_class` takes `None`, and material pins connect by the short names the material editor shows.
|
|
59
|
+
- **Sequence sections**: a sound section plays in the editor and the game, and adding one is one undo step.
|
|
60
|
+
|
|
61
|
+
</details>
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
16
65
|
## 🏷️ [0.6.0] - 2026-10-27
|
|
17
66
|
|
|
18
67
|
> [!NOTE]
|
|
@@ -83,7 +132,7 @@ _Nothing yet._
|
|
|
83
132
|
- **`manage_effect cleanup`** needs destructive consent and a `filter`, is one undo step and refuses Play; debug shapes clear through `debug_shape`.
|
|
84
133
|
- **Object paths** work in `open_level`, `load` and `add_sublevel`; relative `.t3d` exports land in the project.
|
|
85
134
|
- **Git**: any commit message is safe (a quote could inject options); checkout and submit list skipped paths.
|
|
86
|
-
- **`/mcp` discovery**: search rows, `describe` by domain and family, `cursor` and param `suggestions
|
|
135
|
+
- **`/mcp` discovery**: search rows, `describe` by domain and family, `cursor` and param `suggestions`; plain requests ("delete all cubes", "make it rain", "measure distance between actors") reach the capability that answers them.
|
|
87
136
|
- **A disabled tool's `nextCall` turns it back on** (`configure` `enable_tools`).
|
|
88
137
|
|
|
89
138
|
</details>
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
<a href="https://github.com/ChiR24/Unreal_mcp/discussions"><b>💬 Discussions</b></a>
|
|
35
35
|
</p>
|
|
36
36
|
|
|
37
|
-
> 📌 **Which version is this?** This README describes **0.6.0**, the stable release:
|
|
37
|
+
> 📌 **Which version is this?** This README describes **0.6.0**, the stable release: the plugin on [Fab](https://www.fab.com/listings/9407137f-14b3-44a8-af0e-e9c6a9c9f6fc) and on the [Releases page](https://github.com/ChiR24/Unreal_mcp/releases), and npm `unreal-engine-mcp-server` (`latest`). The previous release, **0.5.30**, exposes 23 separate tools instead of one; staying on a 0.5.30 plugin means pinning `unreal-engine-mcp-server@0.5.30`. See [Upgrading from 0.5.x](https://github.com/ChiR24/Unreal_mcp/wiki/Upgrading).
|
|
38
38
|
|
|
39
39
|
**Contents** · [What it does](#what-it-does) · [How it works](#how-it-works) · [Quick start](#quick-start) · [The `unreal` tool](#the-unreal-tool) · [Configuration](#configuration) · [Security](#security) · [Engine plugins](#engine-plugins) · [Docker](#docker) · [Documentation](#documentation) · [Development](#development) · [Community](#community)
|
|
40
40
|
|
|
@@ -85,7 +85,7 @@ Run Play-In-Editor with synthetic input, take screenshots the model can see, rea
|
|
|
85
85
|
</tr>
|
|
86
86
|
</table>
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
Over 450 capabilities in all, behind a single MCP tool. The assistant finds them by searching in plain words, so nobody has to learn their names. The full list is the generated [Action Reference](https://github.com/ChiR24/Unreal_mcp/blob/dev/docs/action-reference.generated.md).
|
|
89
89
|
|
|
90
90
|
<p align="center">
|
|
91
91
|
<img src="https://raw.githubusercontent.com/wiki/ChiR24/Unreal_mcp/assets/screenshots/editor.webp" alt="Unreal Editor 5.8 showing a platformer level built with the MCP; the status bar reads MCP :3000 (1)" width="100%">
|
|
@@ -116,7 +116,7 @@ The [Quick Start](https://github.com/ChiR24/Unreal_mcp/wiki/Quick-Start) page wa
|
|
|
116
116
|
|
|
117
117
|
### 1. Add the plugin
|
|
118
118
|
|
|
119
|
-
**From Fab (no compiler needed).** Get [EngineWire on Fab](https://www.fab.com/listings/9407137f-14b3-44a8-af0e-e9c6a9c9f6fc). In the Epic Games Launcher, open **Unreal Engine › Library › Fab Library**, find **EngineWire** and click **Install to Engine** for your engine version. Open your project, enable **EngineWire** in **Edit › Plugins** and restart the editor.
|
|
119
|
+
**From Fab (no compiler needed).** Get [EngineWire on Fab](https://www.fab.com/listings/9407137f-14b3-44a8-af0e-e9c6a9c9f6fc). In the Epic Games Launcher, open **Unreal Engine › Library › Fab Library**, find **EngineWire** and click **Install to Engine** for your engine version. Open your project, enable **EngineWire** in **Edit › Plugins** and restart the editor. Fab's 0.6.0 build ships with the native server off: if the status bar reads **`MCP off`**, turn on **Enable Native MCP Server** in **Edit › Project Settings › Plugins › EngineWire › Native MCP** and restart once more. Fab updates follow Epic's review, so the Fab build can trail the newest release here.
|
|
120
120
|
|
|
121
121
|
**From a release.** Download `EngineWire-plugin-<version>.zip` from the newest release on the [Releases page](https://github.com/ChiR24/Unreal_mcp/releases), and copy the `EngineWire` folder it contains into your project:
|
|
122
122
|
|
|
@@ -141,7 +141,7 @@ Build the plugin once on a machine with the engine and a compiler, then hand out
|
|
|
141
141
|
node scripts/package-plugin.mjs "C:/Program Files/Epic Games/UE_5.7"
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
This writes `build/EngineWire-v<version>-UE5.7-<Platform>.zip`, where `<version>` is the `package.json` version (currently `0.6.
|
|
144
|
+
This writes `build/EngineWire-v<version>-UE5.7-<Platform>.zip`, where `<version>` is the `package.json` version (currently `0.6.1-beta-a`). Unzip it into `YourProject/Plugins/`. Binaries only work with the engine minor and platform they were built for: a 5.6 build won't load in 5.5, 5.7 or 5.8.
|
|
145
145
|
|
|
146
146
|
</details>
|
|
147
147
|
|
|
@@ -149,7 +149,7 @@ This writes `build/EngineWire-v<version>-UE5.7-<Platform>.zip`, where `<version>
|
|
|
149
149
|
|
|
150
150
|
**Route A · Native HTTP (no Node.js)**
|
|
151
151
|
|
|
152
|
-
1. **Enable Native MCP Server** is on by default (**Edit › Project Settings › Plugins › EngineWire**, port `3000`);
|
|
152
|
+
1. **Enable Native MCP Server** is on by default (**Edit › Project Settings › Plugins › EngineWire**, port `3000`); Fab's 0.6.0 build ships with it off. A project where it is off needs it on and an editor restart.
|
|
153
153
|
|
|
154
154
|
<img src="https://raw.githubusercontent.com/wiki/ChiR24/Unreal_mcp/assets/screenshots/settings-native-mcp.png" alt="The Native MCP section of the plugin settings, with Enable Native MCP Server ticked" width="520">
|
|
155
155
|
|
|
@@ -200,6 +200,8 @@ Add this to your client's MCP configuration, for example Claude Desktop's `claud
|
|
|
200
200
|
}
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
+
Or let the package write it (0.6.1-beta-a and later, so `@beta` until 0.6.1 is the stable release): `npx unreal-engine-mcp-server@beta init claude --project C:/Path/To/YourProject` shows the entry for Claude Code's `.mcp.json` (also `cursor`, `vscode`, `gemini` and `codex`), keeping the file's other servers, and `--write` saves it. `npx unreal-engine-mcp-server@beta doctor` checks Node, the token (never shown), the editor's answer and that both sides run the same version.
|
|
204
|
+
|
|
203
205
|
`UE_PROJECT_PATH` (the project folder or its `.uproject`) is how the proxy finds the capability token (`MCP_AUTOMATION_CAPABILITY_TOKEN` overrides it). `UNREAL_MCP_URL` points it at another endpoint (default `http://127.0.0.1:3000/mcp`). Pair it with a 0.6 plugin; a 0.5.x plugin serves its 23 separate tools instead of the `unreal` gateway.
|
|
204
206
|
|
|
205
207
|
### 3. Try it
|
|
@@ -241,7 +243,7 @@ A typical exchange:
|
|
|
241
243
|
```
|
|
242
244
|
|
|
243
245
|
- **Parameters are strict.** An undeclared name is refused with `UNDECLARED_PARAMETER` and the list of allowed names; every error carries an executable `nextCall`.
|
|
244
|
-
- **Deletes need consent.**
|
|
246
|
+
- **Deletes need consent.** Every destructive capability, plus some risky writes, only runs with the `consentGrant` from `describe`, passed back as a top-level `consent` field.
|
|
245
247
|
- **Names.** A call names the `tool` + `action` pair or a capability id, such as `"capability": "control_actor.spawn"`.
|
|
246
248
|
|
|
247
249
|
Full reference with real replies: [Using the Gateway](https://github.com/ChiR24/Unreal_mcp/wiki/Using-the-Gateway).
|
|
@@ -314,7 +316,7 @@ Most setups touch one setting: `UE_PROJECT_PATH` for Route B; Route A needs none
|
|
|
314
316
|
|
|
315
317
|
## Security
|
|
316
318
|
|
|
317
|
-
- **Local by default.** Both routes listen on `127.0.0.1` only.
|
|
319
|
+
- **Local by default.** Both routes listen on `127.0.0.1` only. The native server never binds off-loopback: it has no TLS, so reach it from another machine through an SSH tunnel (`ssh -L 3000:127.0.0.1:3000`) or a TLS proxy. The WebSocket bridge binds a LAN address only with **Allow Non Loopback**, **Require Capability Token** and **Enable Tls** all on. The proxy sends the token to another machine only with `UNREAL_MCP_ALLOW_REMOTE_TOKEN=1`.
|
|
318
320
|
- **Capability token.** Generated per project at `<Project>/Saved/MCP/capability-token` (a value typed into **Capability Token** overrides it) and compared in constant time. Delete the file and restart the editor to rotate it.
|
|
319
321
|
- **Consent for destructive work.** Deletes and some other writes need a per-call consent grant, which the plugin checks itself.
|
|
320
322
|
- **Guard rails.** Asset paths are limited to `/Game`, `/Engine`, `/Script`, `/Temp` and `/Niagara`, and a content path may also sit under a mount the connected editor reports; arguments the server treats as files (such as `filePath`, and `outputPath` even where it names an asset) never use the reported mounts; console commands that chain or quit the editor are blocked; the plugin's own settings are out of reach of automation.
|
|
@@ -337,6 +339,8 @@ The bridge declares its engine-plugin dependencies, so Unreal enables them toget
|
|
|
337
339
|
| **Niagara** | Visual effects |
|
|
338
340
|
| **Gameplay Abilities** | `manage_gas` |
|
|
339
341
|
| **Smart Objects** | AI smart objects |
|
|
342
|
+
| **Movie Render Pipeline**, **Movie Pipeline Mask Render Pass**, **Takes** | `manage_sequence`: Movie Render Queue and Take Recorder |
|
|
343
|
+
| **MetaSound** | `manage_audio`: MetaSound authoring |
|
|
340
344
|
|
|
341
345
|
</details>
|
|
342
346
|
|
|
@@ -347,14 +351,16 @@ The bridge declares its engine-plugin dependencies, so Unreal enables them toget
|
|
|
347
351
|
|
|
348
352
|
| Plugin | Used for |
|
|
349
353
|
| --- | --- |
|
|
350
|
-
| **Level Sequence Editor**, **
|
|
354
|
+
| **Level Sequence Editor**, **Electra Player** | `manage_sequence`: Sequencer, media playback |
|
|
351
355
|
| **Control Rig**, **RigVM**, **IK Rig**, **Animation Data** | `animation_physics`: Control Rig and IK |
|
|
352
356
|
| **Chaos Vehicles**, **Chaos Cloth** | `animation_physics`: vehicles and cloth |
|
|
353
357
|
| **Niagara Editor** | `manage_effect`: Niagara authoring |
|
|
354
358
|
| **Behavior Tree Editor**, **Environment Query Editor**, **StateTree**, **Mass Gameplay** | `manage_ai` |
|
|
355
359
|
| **Geometry Scripting**, **Geometry Processing**, **Mesh Modeling Toolset**, **Procedural Mesh Component** | `manage_geometry` (Catmull-Clark, Loop and bilinear subdivision need Mesh Modeling Toolset) |
|
|
356
360
|
| **PCG** | `manage_pcg`, compiled in only when the project itself enables the PCG plugin |
|
|
357
|
-
| **
|
|
361
|
+
| **Game Features** | `system_control`: game feature plugins (listed, activated, made with `create_plugin`) through the `EngineWireGameFeatures` module, loaded only when the project itself enables Game Features (EngineWire never turns it on) |
|
|
362
|
+
| **Data Registry** | `manage_asset`: data registries listed, read and made over data tables, by reflection when the project itself enables Data Registry (EngineWire never turns it on) |
|
|
363
|
+
| **Synthesis** | `manage_audio`: source effect presets |
|
|
358
364
|
| **Enhanced Input**, **Online Subsystem**, **Online Subsystem Utils** | `manage_networking`: input mappings, sessions |
|
|
359
365
|
| **Interchange**, **Interchange OpenUSD**, **Data Validation**, **StructUtils** | Import/export, validation, struct helpers |
|
|
360
366
|
| **Fab**, **Bridge** | Fab asset library access (optional `EngineWireFab` module) |
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { runDoctor } from './proxy/doctor.js';
|
|
3
|
+
import { runInit } from './proxy/init.js';
|
|
2
4
|
import { startNativeProxy } from './proxy/native-proxy.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
6
|
+
if (command === 'doctor') {
|
|
7
|
+
process.exitCode = await runDoctor();
|
|
8
|
+
}
|
|
9
|
+
else if (command === 'init') {
|
|
10
|
+
const result = runInit(rest);
|
|
11
|
+
process.stdout.write(`${result.content ? `${result.file}\n\n${result.content}\n` : ''}${result.message}\n`);
|
|
12
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
startNativeProxy();
|
|
16
|
+
process.stdin.once('end', () => process.exit(0));
|
|
17
|
+
}
|
|
5
18
|
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { request } from 'node:http';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { PACKAGE } from '../constants.js';
|
|
5
|
+
import { projectRootFromEnv } from '../utils/config/ini-reader.js';
|
|
6
|
+
import { oneLine } from '../utils/logging/logger.js';
|
|
7
|
+
import { readCapabilityToken, sendsTokenTo } from './native-proxy.js';
|
|
8
|
+
const NODE_FLOOR = [20, 19];
|
|
9
|
+
export function nodeLine(version = process.versions.node) {
|
|
10
|
+
const [major = 0, minor = 0] = version.split('.').map(Number);
|
|
11
|
+
const fits = major > NODE_FLOOR[0] || (major === NODE_FLOOR[0] && minor >= NODE_FLOOR[1]);
|
|
12
|
+
return fits ? { level: 'ok', text: `Node ${version}` }
|
|
13
|
+
: { level: 'FAIL', text: `Node ${version} is older than ${NODE_FLOOR.join('.')}: install Node ${NODE_FLOOR.join('.')} or later` };
|
|
14
|
+
}
|
|
15
|
+
export function tokenLine(token) {
|
|
16
|
+
if (process.env.MCP_AUTOMATION_CAPABILITY_TOKEN) {
|
|
17
|
+
return { level: 'ok', text: 'Capability token from MCP_AUTOMATION_CAPABILITY_TOKEN (its value is not shown)' };
|
|
18
|
+
}
|
|
19
|
+
const root = projectRootFromEnv();
|
|
20
|
+
if (!root)
|
|
21
|
+
return { level: 'warn', text: 'No capability token: set UE_PROJECT_PATH to the project folder (or MCP_AUTOMATION_CAPABILITY_TOKEN)' };
|
|
22
|
+
if (!existsSync(root))
|
|
23
|
+
return { level: 'warn', text: `UE_PROJECT_PATH names ${root}, which does not exist` };
|
|
24
|
+
const file = join(root, 'Saved', 'MCP', 'capability-token');
|
|
25
|
+
return token ? { level: 'ok', text: `Capability token found in ${file} (its value is not shown)` }
|
|
26
|
+
: { level: 'warn', text: `No valid token in ${file}: open the project in the editor once, so the plugin writes it` };
|
|
27
|
+
}
|
|
28
|
+
function send(url, method, headers, body = '') {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const sent = request(url, { method, agent: false, timeout: 5000, headers: { Connection: 'close', ...headers } }, (reply) => {
|
|
31
|
+
let text = '';
|
|
32
|
+
reply.setEncoding('utf8');
|
|
33
|
+
reply.on('data', (chunk) => { text += chunk; });
|
|
34
|
+
reply.on('end', () => resolve({ status: reply.statusCode ?? 0, headers: reply.headers, text }));
|
|
35
|
+
reply.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
sent.on('timeout', () => sent.destroy(new Error('no answer within 5 s')));
|
|
38
|
+
sent.on('error', reject);
|
|
39
|
+
sent.end(body);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function initializeAnswer(text) {
|
|
43
|
+
const json = text.trimStart().startsWith('{') ? text : text.split(/\r?\n/u).find((line) => line.startsWith('data:'))?.slice(5) ?? '';
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(json) ?? {};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function doctorLines(url = process.env.UNREAL_MCP_URL ?? 'http://127.0.0.1:3000/mcp') {
|
|
52
|
+
const found = await readCapabilityToken();
|
|
53
|
+
const lines = [nodeLine(), tokenLine(found)];
|
|
54
|
+
const token = sendsTokenTo(url) ? found : undefined;
|
|
55
|
+
if (found && !token) {
|
|
56
|
+
lines.push({ level: 'warn', text: `${url} is not this machine, so the token is not sent over plain HTTP; set UNREAL_MCP_ALLOW_REMOTE_TOKEN=1 to send it anyway` });
|
|
57
|
+
}
|
|
58
|
+
const auth = token ? { 'X-MCP-Capability-Token': token } : {};
|
|
59
|
+
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25',
|
|
60
|
+
capabilities: {}, clientInfo: { name: `${PACKAGE.name} doctor`, version: PACKAGE.version } } });
|
|
61
|
+
let hello;
|
|
62
|
+
try {
|
|
63
|
+
hello = await send(url, 'POST', { ...auth, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
|
|
64
|
+
'Content-Length': Buffer.byteLength(body) }, body);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
lines.push({ level: 'FAIL', text: `Nothing answers at ${url} (${error instanceof Error ? error.message : String(error)}): start the `
|
|
68
|
+
+ 'editor; if it runs, turn on Project Settings > Plugins > EngineWire > Enable Native MCP Server, or set UNREAL_MCP_URL' });
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
const session = hello.headers['mcp-session-id'];
|
|
72
|
+
if (typeof session === 'string')
|
|
73
|
+
await send(url, 'DELETE', { ...auth, 'Mcp-Session-Id': session }).catch(() => undefined);
|
|
74
|
+
const { result, error } = initializeAnswer(hello.text);
|
|
75
|
+
const server = result?.serverInfo;
|
|
76
|
+
if (hello.status === 401 || hello.status === 403) {
|
|
77
|
+
lines.push({ level: 'FAIL', text: `The editor at ${url} refused this client (HTTP ${hello.status}): the token is missing or not this project's` });
|
|
78
|
+
}
|
|
79
|
+
else if (!server?.version) {
|
|
80
|
+
lines.push({ level: 'FAIL', text: `${url} answered HTTP ${hello.status}, ${error?.message ? `refusing: ${oneLine(error.message)}` : 'not an MCP initialize reply'}` });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
lines.push({ level: 'ok', text: `Editor answers at ${url}: ${oneLine(server.name ?? 'server')} ${oneLine(server.version)}, protocol ${oneLine(result?.protocolVersion ?? '?')}` });
|
|
84
|
+
lines.push(server.version === PACKAGE.version ? { level: 'ok', text: `Proxy and plugin are both ${PACKAGE.version}` }
|
|
85
|
+
: { level: 'warn', text: `Proxy ${PACKAGE.version} and plugin ${oneLine(server.version)} differ: update the older one` });
|
|
86
|
+
}
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
export async function runDoctor(write = (text) => { process.stdout.write(text); }) {
|
|
90
|
+
const lines = await doctorLines();
|
|
91
|
+
write(`${PACKAGE.name} ${PACKAGE.version} doctor\n${lines.map((line) => `${line.level.padEnd(4)} ${line.text}`).join('\n')}\n`);
|
|
92
|
+
return lines.some((line) => line.level === 'FAIL') ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { PACKAGE } from '../constants.js';
|
|
5
|
+
export const CLIENTS = {
|
|
6
|
+
claude: { file: '.mcp.json', key: 'mcpServers' },
|
|
7
|
+
cursor: { file: '.cursor/mcp.json', key: 'mcpServers' },
|
|
8
|
+
vscode: { file: '.vscode/mcp.json', key: 'servers', type: 'stdio' },
|
|
9
|
+
gemini: { file: '.gemini/settings.json', key: 'mcpServers' },
|
|
10
|
+
codex: { file: '.codex/config.toml', home: true, toml: true },
|
|
11
|
+
};
|
|
12
|
+
const NAME = 'unreal-engine';
|
|
13
|
+
export function projectFor(argument, cwd, env) {
|
|
14
|
+
const named = argument ?? env.UE_PROJECT_PATH;
|
|
15
|
+
if (named)
|
|
16
|
+
return (isAbsolute(named) ? named : resolve(cwd, named)).replace(/\\/gu, '/');
|
|
17
|
+
return readdirSync(cwd).some((file) => file.toLowerCase().endsWith('.uproject')) ? cwd.replace(/\\/gu, '/') : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function launch(platform) {
|
|
20
|
+
const npx = ['-y', PACKAGE.name];
|
|
21
|
+
return platform === 'win32' ? { command: 'cmd', args: ['/c', 'npx', ...npx] } : { command: 'npx', args: npx };
|
|
22
|
+
}
|
|
23
|
+
export function serverEntry(client, project, platform) {
|
|
24
|
+
return { ...(client.type ? { type: client.type } : {}), ...launch(platform), ...(project ? { env: { UE_PROJECT_PATH: project } } : {}) };
|
|
25
|
+
}
|
|
26
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
+
export function mergeJson(existing, client, entry) {
|
|
28
|
+
const text = existing?.replace(/^\uFEFF/u, '').trim();
|
|
29
|
+
const config = text ? JSON.parse(text) : {};
|
|
30
|
+
const key = client.key ?? 'mcpServers';
|
|
31
|
+
const servers = isObject(config) ? config[key] ?? {} : undefined;
|
|
32
|
+
if (!isObject(config) || !isObject(servers))
|
|
33
|
+
throw new Error(`not an object with "${key}"`);
|
|
34
|
+
config[key] = { ...servers, [NAME]: entry };
|
|
35
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
36
|
+
}
|
|
37
|
+
export function tomlBlock(project, platform) {
|
|
38
|
+
const { command, args } = launch(platform);
|
|
39
|
+
return `[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((arg) => JSON.stringify(arg)).join(', ')}]\n`
|
|
40
|
+
+ (project ? `\n[mcp_servers.${NAME}.env]\nUE_PROJECT_PATH = ${JSON.stringify(project)}\n` : '');
|
|
41
|
+
}
|
|
42
|
+
export function runInit(argv, options = {}) {
|
|
43
|
+
const cwd = options.cwd ?? process.cwd();
|
|
44
|
+
const env = options.env ?? process.env;
|
|
45
|
+
const platform = options.platform ?? process.platform;
|
|
46
|
+
let name;
|
|
47
|
+
let projectArg;
|
|
48
|
+
for (let index = 0; index < argv.length; index++) {
|
|
49
|
+
if (argv[index] === '--project')
|
|
50
|
+
projectArg = argv[++index];
|
|
51
|
+
else if (!argv[index].startsWith('--'))
|
|
52
|
+
name ??= argv[index];
|
|
53
|
+
}
|
|
54
|
+
const client = name ? CLIENTS[name] : undefined;
|
|
55
|
+
if (!client) {
|
|
56
|
+
return { ok: false, file: '', content: '', written: false, message: `usage: ${PACKAGE.name} init <${Object.keys(CLIENTS).join('|')}> `
|
|
57
|
+
+ '[--project <folder>] [--write]' };
|
|
58
|
+
}
|
|
59
|
+
const project = projectFor(projectArg, cwd, env);
|
|
60
|
+
const file = join(client.home ? options.home ?? homedir() : cwd, client.file);
|
|
61
|
+
const existing = existsSync(file) ? readFileSync(file, 'utf8') : undefined;
|
|
62
|
+
let content;
|
|
63
|
+
if (client.toml) {
|
|
64
|
+
if (existing && /^\s*\[\s*mcp_servers\s*\.\s*"?unreal-engine"?\s*[\].]/mu.test(existing)) {
|
|
65
|
+
return { ok: false, file, content: tomlBlock(project, platform), written: false,
|
|
66
|
+
message: `${file} already has [mcp_servers.${NAME}]; it is left as it is, so edit it to read as above` };
|
|
67
|
+
}
|
|
68
|
+
content = `${existing?.trim() ? `${existing.trimEnd()}\n\n` : ''}${tomlBlock(project, platform)}`;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
try {
|
|
72
|
+
content = mergeJson(existing, client, serverEntry(client, project, platform));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { ok: false, file, content: '', written: false,
|
|
76
|
+
message: `${file} is not a plain JSON object with "${client.key}" (comments?): add the "${NAME}" entry by hand` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const write = argv.includes('--write');
|
|
80
|
+
if (write) {
|
|
81
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
82
|
+
writeFileSync(file, content);
|
|
83
|
+
}
|
|
84
|
+
const noProject = project ? '' : ' No project folder was found, so UE_PROJECT_PATH is left out: pass --project <folder>.';
|
|
85
|
+
return { ok: true, file, content, written: write,
|
|
86
|
+
message: (write ? `Written to ${file}.` : `Not written: run again with --write to save it to ${file}.`) + noProject };
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -44,6 +44,10 @@ export async function readCapabilityToken() {
|
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
export function sendsTokenTo(upstream) {
|
|
48
|
+
const host = new URL(upstream).hostname.replace(/^\[|\]$/gu, '');
|
|
49
|
+
return /^(localhost|127(\.\d{1,3}){3}|::1)$/u.test(host) || process.env.UNREAL_MCP_ALLOW_REMOTE_TOKEN === '1';
|
|
50
|
+
}
|
|
47
51
|
export function startNativeProxy(options = {}) {
|
|
48
52
|
const upstream = options.upstream ?? process.env.UNREAL_MCP_URL ?? 'http://127.0.0.1:3000/mcp';
|
|
49
53
|
const cacheFile = options.cacheFile ?? process.env.UNREAL_MCP_PROXY_CACHE
|
|
@@ -51,6 +55,9 @@ export function startNativeProxy(options = {}) {
|
|
|
51
55
|
const output = options.output ?? process.stdout;
|
|
52
56
|
const log = new Logger('NativeProxy');
|
|
53
57
|
const token = options.token ?? readCapabilityToken;
|
|
58
|
+
const sendsToken = sendsTokenTo(upstream);
|
|
59
|
+
if (!sendsToken)
|
|
60
|
+
log.warn(`${upstream} is not this machine, so the capability token is not sent over plain HTTP; set UNREAL_MCP_ALLOW_REMOTE_TOKEN=1 to send it anyway.`);
|
|
54
61
|
const notConnected = `NOT_CONNECTED: the Unreal Editor is not running (nothing answers at ${upstream}). `
|
|
55
62
|
+ 'Start the editor and call again; this server stays connected, no reconnect is needed. If the editor is '
|
|
56
63
|
+ 'running, turn on Project Settings > EngineWire > Enable Native MCP Server and restart it.';
|
|
@@ -86,7 +93,7 @@ export function startNativeProxy(options = {}) {
|
|
|
86
93
|
'Content-Length': Buffer.byteLength(payload),
|
|
87
94
|
...modern
|
|
88
95
|
};
|
|
89
|
-
const capabilityToken = await token();
|
|
96
|
+
const capabilityToken = sendsToken ? await token() : undefined;
|
|
90
97
|
if (capabilityToken)
|
|
91
98
|
headers['X-MCP-Capability-Token'] = capabilityToken;
|
|
92
99
|
if (current) {
|
|
@@ -99,6 +106,7 @@ export function startNativeProxy(options = {}) {
|
|
|
99
106
|
let reply;
|
|
100
107
|
let text = '';
|
|
101
108
|
let pending = '';
|
|
109
|
+
let scanned = 0;
|
|
102
110
|
const take = (message) => {
|
|
103
111
|
if (message.id !== undefined && message.id === body.id && ('result' in message || 'error' in message))
|
|
104
112
|
reply = message;
|
|
@@ -122,10 +130,11 @@ export function startNativeProxy(options = {}) {
|
|
|
122
130
|
if (text.length < 2000)
|
|
123
131
|
text += chunk;
|
|
124
132
|
pending += chunk.replace(/\r/g, '');
|
|
125
|
-
for (let cut = pending.indexOf('\n\n'); cut >= 0; cut = pending.indexOf('\n\n')) {
|
|
133
|
+
for (let cut = pending.indexOf('\n\n', scanned); cut >= 0; cut = pending.indexOf('\n\n')) {
|
|
126
134
|
takeEvent(pending.slice(0, cut));
|
|
127
135
|
pending = pending.slice(cut + 2);
|
|
128
136
|
}
|
|
137
|
+
scanned = Math.max(0, pending.length - 1);
|
|
129
138
|
});
|
|
130
139
|
res.on('end', () => {
|
|
131
140
|
if (sse && pending.trim())
|
|
@@ -8,6 +8,12 @@ function normalizeLogLevel(value, fallback) {
|
|
|
8
8
|
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
9
9
|
return Object.hasOwn(LOG_LEVEL_ORDER, normalized) ? normalized : fallback;
|
|
10
10
|
}
|
|
11
|
+
export function oneLine(text) {
|
|
12
|
+
return Array.from(text, (c) => {
|
|
13
|
+
const code = c.charCodeAt(0);
|
|
14
|
+
return (code < 0x20 && c !== '\t') || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : c;
|
|
15
|
+
}).join('');
|
|
16
|
+
}
|
|
11
17
|
export class Logger {
|
|
12
18
|
scope;
|
|
13
19
|
level;
|
|
@@ -20,7 +26,7 @@ export class Logger {
|
|
|
20
26
|
}
|
|
21
27
|
write(level, args) {
|
|
22
28
|
if (this.isEnabled(level))
|
|
23
|
-
console.error(`[${this.scope}]`, ...args);
|
|
29
|
+
console.error(`[${this.scope}]`, ...args.map((arg) => typeof arg === 'string' ? oneLine(arg) : arg));
|
|
24
30
|
}
|
|
25
31
|
debug(...args) { this.write('debug', args); }
|
|
26
32
|
info(...args) { this.write('info', args); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unreal-engine-mcp-server",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1-beta-a",
|
|
4
4
|
"mcpName": "io.github.ChiR24/unreal-engine-mcp",
|
|
5
5
|
"description": "EngineWire: an MCP server and Unreal Editor plugin that let AI assistants build in Unreal Engine 5.0-5.8: levels, Blueprints, UI, materials, effects and more.",
|
|
6
6
|
"homepage": "https://enginewire.dev",
|
package/server.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"name": "io.github.ChiR24/unreal-engine-mcp",
|
|
4
4
|
"description": "EngineWire: drive the Unreal Editor (UE 5.0-5.8): levels, Blueprints, UI, materials, VFX and more.",
|
|
5
5
|
"websiteUrl": "https://enginewire.dev",
|
|
6
|
-
"version": "0.6.
|
|
6
|
+
"version": "0.6.1-beta-a",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
11
11
|
"identifier": "unreal-engine-mcp-server",
|
|
12
|
-
"version": "0.6.
|
|
12
|
+
"version": "0.6.1-beta-a",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
15
15
|
},
|