@biorand/reframework-mcp 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/dist/index.js +531 -0
- package/dist/mcp.lua +167 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BioRand Team.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @biorand/reframework-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for running Lua inside RE Engine games via
|
|
4
|
+
[REFramework](https://github.com/praydog/REFramework).
|
|
5
|
+
|
|
6
|
+
## Add to your MCP servers
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"mcpServers": {
|
|
11
|
+
"reframework": {
|
|
12
|
+
"command": "npx",
|
|
13
|
+
"args": ["-y", "@biorand/reframework-mcp"]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Pin a version if you want reproducible setups, e.g. `"@biorand/reframework-mcp@1.1.0"`.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
`REF_DATA_DIR` is optional. On first use, ask your agent to run the `install_bridge`
|
|
24
|
+
tool with your game install directory (e.g. `C:\Games\RE9`). It installs the Lua
|
|
25
|
+
bridge into `reframework\autorun`, points the server at `reframework\data`, and
|
|
26
|
+
remembers the choice. Reload the Lua scripts in-game (REFramework menu →
|
|
27
|
+
ScriptRunner → Reset Scripts, or restart the game) after the bridge is installed
|
|
28
|
+
or updated.
|
|
29
|
+
|
|
30
|
+
You can also set the environment variable explicitly when registering the server:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
"env": {
|
|
34
|
+
"REF_DATA_DIR": "C:\\Games\\RE9\\reframework\\data"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | Description |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `execute_code` | Execute Lua inside a running game via REFramework. |
|
|
43
|
+
| `install_bridge` | Install/update the Lua bridge in a game and activate its data directory. |
|
|
44
|
+
| `set_data_dir` | Point the server at an existing `reframework\data` directory. |
|
|
45
|
+
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
- Node.js 18+
|
|
49
|
+
- A supported RE Engine game with REFramework installed
|
|
50
|
+
|
|
51
|
+
See the [repository README](https://github.com/biorand/reframework-mcp) for the
|
|
52
|
+
full documentation, environment variables and troubleshooting.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* REFramework MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Tools:
|
|
6
|
+
* execute_code – run Lua inside a running RE Engine game via REFramework
|
|
7
|
+
* install_bridge – install the bundled mcp.lua into a game's reframework\autorun
|
|
8
|
+
* set_data_dir – configure (and remember) the reframework\data directory
|
|
9
|
+
*
|
|
10
|
+
* Workflow (execute_code):
|
|
11
|
+
* 1. Write Lua code to <dataDir>/mcp_input.lua
|
|
12
|
+
* 2. Poll until the file is empty (the in-game bridge blanks it after running)
|
|
13
|
+
* 3. Read <dataDir>/mcp_output.txt and return its contents
|
|
14
|
+
*
|
|
15
|
+
* Configuration (env vars):
|
|
16
|
+
* REF_DATA_DIR – absolute path to the reframework/data folder (optional; if
|
|
17
|
+
* omitted the agent can set it with install_bridge/set_data_dir
|
|
18
|
+
* and the choice is persisted between server starts)
|
|
19
|
+
* POLL_INTERVAL – ms between polls while waiting (default: 500)
|
|
20
|
+
* POLL_TIMEOUT – ms before giving up (default: 30000)
|
|
21
|
+
* REF_MCP_CONFIG – override the persisted config file location
|
|
22
|
+
* REF_MCP_LUA – override the bundled mcp.lua source path
|
|
23
|
+
*/
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import fs from "node:fs/promises";
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
// ── config ────────────────────────────────────────────────────────────────────
|
|
35
|
+
const POLL_INTERVAL = Number(process.env.POLL_INTERVAL ?? 500);
|
|
36
|
+
const POLL_TIMEOUT = Number(process.env.POLL_TIMEOUT ?? 30_000);
|
|
37
|
+
/**
|
|
38
|
+
* Marker written into mcp_input.lua and echoed into mcp_output.txt so output
|
|
39
|
+
* can be matched to the request that produced it.
|
|
40
|
+
*/
|
|
41
|
+
const REQUEST_ID_PATTERN = /refmcp_request_id:\s*([A-Za-z0-9-]+)/;
|
|
42
|
+
/** File where the runtime data-dir choice is persisted between server starts. */
|
|
43
|
+
const CONFIG_PATH = process.env.REF_MCP_CONFIG ??
|
|
44
|
+
(process.platform === "win32"
|
|
45
|
+
? path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "reframework-mcp", "config.json")
|
|
46
|
+
: path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "reframework-mcp", "config.json"));
|
|
47
|
+
function loadConfig() {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
50
|
+
if (parsed && typeof parsed === "object") {
|
|
51
|
+
const { dataDir } = parsed;
|
|
52
|
+
if (typeof dataDir === "string" && dataDir)
|
|
53
|
+
return { dataDir };
|
|
54
|
+
}
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const envDataDir = process.env.REF_DATA_DIR ? path.resolve(process.env.REF_DATA_DIR) : null;
|
|
62
|
+
const savedDataDir = loadConfig().dataDir;
|
|
63
|
+
/** Active data directory; null until configured via env, config file or a tool. */
|
|
64
|
+
let dataDir = envDataDir ?? (savedDataDir ? path.resolve(savedDataDir) : null);
|
|
65
|
+
async function persistDataDir(dir) {
|
|
66
|
+
try {
|
|
67
|
+
const config = loadConfig();
|
|
68
|
+
config.dataDir = dir;
|
|
69
|
+
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
|
70
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
process.stderr.write(`[reframework-mcp] warning: could not save config to ${CONFIG_PATH}: ${String(err)}\n`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function requireDataDir() {
|
|
79
|
+
if (!dataDir) {
|
|
80
|
+
throw new Error("No reframework data directory is configured.\n" +
|
|
81
|
+
" • If REFramework is already installed, call set_data_dir with the game install " +
|
|
82
|
+
"directory (e.g. C:\\Games\\RE9) or with <game>\\reframework\\data.\n" +
|
|
83
|
+
" • Otherwise call install_bridge with the game install directory to install the " +
|
|
84
|
+
"Lua bridge and configure this automatically.\n" +
|
|
85
|
+
"Alternatively set REF_DATA_DIR when registering this MCP server.");
|
|
86
|
+
}
|
|
87
|
+
return dataDir;
|
|
88
|
+
}
|
|
89
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
90
|
+
function sleep(ms) {
|
|
91
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read a file, returning "" if it doesn't exist yet.
|
|
95
|
+
*/
|
|
96
|
+
async function readSafe(filePath) {
|
|
97
|
+
try {
|
|
98
|
+
return await fs.readFile(filePath, "utf8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function isFile(filePath) {
|
|
105
|
+
try {
|
|
106
|
+
return (await fs.stat(filePath)).isFile();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function isDir(filePath) {
|
|
113
|
+
try {
|
|
114
|
+
return (await fs.stat(filePath)).isDirectory();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* True when a directory looks like a reframework folder: named "reframework",
|
|
122
|
+
* or containing an autorun/plugins folder.
|
|
123
|
+
*/
|
|
124
|
+
async function isReframeworkDir(dir) {
|
|
125
|
+
if (path.basename(dir).toLowerCase() === "reframework" && (await isDir(dir)))
|
|
126
|
+
return true;
|
|
127
|
+
return (await isDir(path.join(dir, "autorun"))) || (await isDir(path.join(dir, "plugins")));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Turn a user-supplied path (game install dir, reframework dir, data dir or any
|
|
131
|
+
* path inside the game tree) into the reframework\data directory.
|
|
132
|
+
* Creates the data folder when REFramework exists but the game hasn't started once.
|
|
133
|
+
*/
|
|
134
|
+
async function resolveDataDir(input) {
|
|
135
|
+
let base = path.resolve(input);
|
|
136
|
+
if (await isFile(base))
|
|
137
|
+
base = path.dirname(base);
|
|
138
|
+
const baseName = path.basename(base).toLowerCase();
|
|
139
|
+
// The data directory itself was passed; create it when the parent is a
|
|
140
|
+
// reframework folder and the game has not created it yet.
|
|
141
|
+
if (baseName === "data" && (await isReframeworkDir(path.dirname(base)))) {
|
|
142
|
+
if (!(await isDir(base)))
|
|
143
|
+
await fs.mkdir(base, { recursive: true });
|
|
144
|
+
return base;
|
|
145
|
+
}
|
|
146
|
+
if (!(await isDir(base))) {
|
|
147
|
+
throw new Error(`"${base}" does not exist. Pass the game install directory (e.g. C:\\Games\\RE9), ` +
|
|
148
|
+
`the reframework directory, or the reframework\\data directory.`);
|
|
149
|
+
}
|
|
150
|
+
// A data directory with an unusual name (it already holds MCP files).
|
|
151
|
+
if ((await isFile(path.join(base, "mcp_input.lua"))) ||
|
|
152
|
+
(await isFile(path.join(base, "mcp_output.txt")))) {
|
|
153
|
+
return base;
|
|
154
|
+
}
|
|
155
|
+
// The game install directory was passed: <base>\reframework\data
|
|
156
|
+
if (await isDir(path.join(base, "reframework"))) {
|
|
157
|
+
const dataPath = path.join(base, "reframework", "data");
|
|
158
|
+
await fs.mkdir(dataPath, { recursive: true });
|
|
159
|
+
return dataPath;
|
|
160
|
+
}
|
|
161
|
+
// Any other path inside the game tree (e.g. the autorun folder): walk up to
|
|
162
|
+
// the game install directory.
|
|
163
|
+
const gameRoot = await findGameRoot(base);
|
|
164
|
+
if (gameRoot) {
|
|
165
|
+
const dataPath = path.join(gameRoot, "reframework", "data");
|
|
166
|
+
await fs.mkdir(dataPath, { recursive: true });
|
|
167
|
+
return dataPath;
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Could not find a reframework\\data directory from "${base}".\n` +
|
|
170
|
+
`Pass one of:\n` +
|
|
171
|
+
` - the game install directory (the folder containing the game .exe)\n` +
|
|
172
|
+
` - the reframework directory\n` +
|
|
173
|
+
` - the data directory itself\n` +
|
|
174
|
+
` - any path inside the game install directory\n` +
|
|
175
|
+
`If REFramework is not installed yet, call install_bridge instead.`);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Health check for a resolved data directory. Returns a short human-readable
|
|
179
|
+
* note (or warning) about what was found around it.
|
|
180
|
+
*/
|
|
181
|
+
async function describeDataDir(dir) {
|
|
182
|
+
const refDir = path.dirname(dir);
|
|
183
|
+
const autorunDir = path.join(refDir, "autorun");
|
|
184
|
+
const hasReframework = (await isDir(autorunDir)) || (await isDir(path.join(refDir, "plugins")));
|
|
185
|
+
if (!hasReframework) {
|
|
186
|
+
return (`Warning: this does not look like a reframework\\data directory ` +
|
|
187
|
+
`(no autorun or plugins folder found in "${refDir}"). ` +
|
|
188
|
+
`Make sure REFramework is installed and the path is correct.`);
|
|
189
|
+
}
|
|
190
|
+
const bridge = path.join(autorunDir, "mcp.lua");
|
|
191
|
+
return (await isFile(bridge))
|
|
192
|
+
? `Lua bridge found at ${bridge}.`
|
|
193
|
+
: `Lua bridge (mcp.lua) not found in ${autorunDir} - call install_bridge to install it.`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Walk up from a path until a directory containing a "reframework" folder is
|
|
197
|
+
* found (the game install directory). Returns null when there is none.
|
|
198
|
+
*/
|
|
199
|
+
async function findGameRoot(start) {
|
|
200
|
+
let dir = path.resolve(start);
|
|
201
|
+
if (await isFile(dir))
|
|
202
|
+
dir = path.dirname(dir);
|
|
203
|
+
while (true) {
|
|
204
|
+
if (await isDir(path.join(dir, "reframework")))
|
|
205
|
+
return dir;
|
|
206
|
+
const parent = path.dirname(dir);
|
|
207
|
+
if (parent === dir)
|
|
208
|
+
return null;
|
|
209
|
+
dir = parent;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const KNOWN_GAME_EXES = ["re9.exe", "re8.exe", "re7.exe", "re4.exe", "re3.exe", "re2.exe"];
|
|
213
|
+
/** Best-effort detection of the game executable, for friendlier messages. */
|
|
214
|
+
async function findGameExe(gameRoot) {
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
entries = await fs.readdir(gameRoot);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const byLower = new Map(entries.map((entry) => [entry.toLowerCase(), entry]));
|
|
223
|
+
for (const known of KNOWN_GAME_EXES) {
|
|
224
|
+
const found = byLower.get(known);
|
|
225
|
+
if (found)
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
for (const [lower, original] of byLower) {
|
|
229
|
+
if (/^re[0-9].*\.exe$/.test(lower))
|
|
230
|
+
return original;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/** Candidate locations of the bundled Lua bridge source. */
|
|
235
|
+
function luaSourceCandidates() {
|
|
236
|
+
const candidates = [
|
|
237
|
+
process.env.REF_MCP_LUA,
|
|
238
|
+
path.join(__dirname, "..", "..", "lua", "mcp.lua"), // repo layout: ts/dist -> lua/mcp.lua (live edits win)
|
|
239
|
+
path.join(__dirname, "mcp.lua"), // copied next to dist/index.js by the build (packaged)
|
|
240
|
+
path.join(__dirname, "..", "lua", "mcp.lua"),
|
|
241
|
+
];
|
|
242
|
+
return candidates.filter((candidate) => Boolean(candidate));
|
|
243
|
+
}
|
|
244
|
+
async function findLuaSource() {
|
|
245
|
+
for (const candidate of luaSourceCandidates()) {
|
|
246
|
+
if (await isFile(candidate))
|
|
247
|
+
return candidate;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/** Checklist-style error for a timed-out execute_code call. */
|
|
252
|
+
async function buildTimeoutError(inputPath) {
|
|
253
|
+
const refDir = path.dirname(path.dirname(inputPath));
|
|
254
|
+
const bridgePath = path.join(refDir, "autorun", "mcp.lua");
|
|
255
|
+
const bridgeHint = (await isFile(bridgePath))
|
|
256
|
+
? `Lua bridge found at ${bridgePath}.`
|
|
257
|
+
: `Lua bridge NOT found at ${bridgePath} - call install_bridge to install it.`;
|
|
258
|
+
return new Error(`Timed out after ${POLL_TIMEOUT} ms waiting for the Lua bridge to consume the input file.\n` +
|
|
259
|
+
`Checklist:\n` +
|
|
260
|
+
` 1. The game is running with REFramework loaded.\n` +
|
|
261
|
+
` 2. ${bridgeHint}\n` +
|
|
262
|
+
` 3. If the bridge was just installed or updated, reload the Lua scripts in-game ` +
|
|
263
|
+
`(REFramework menu > ScriptRunner > Reset Scripts) or restart the game.\n` +
|
|
264
|
+
` 4. Look for "[mcp]" messages in the REFramework log (in the game directory).`);
|
|
265
|
+
}
|
|
266
|
+
// ── tool implementations ──────────────────────────────────────────────────────
|
|
267
|
+
// Tool calls share state (the input/output files, the active data dir and the
|
|
268
|
+
// config file), so run them one at a time.
|
|
269
|
+
let queue = Promise.resolve();
|
|
270
|
+
function enqueue(task) {
|
|
271
|
+
const result = queue.then(task);
|
|
272
|
+
queue = result.then(() => undefined, () => undefined);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
async function executeCode(code) {
|
|
276
|
+
const dir = requireDataDir();
|
|
277
|
+
const inputPath = path.join(dir, "mcp_input.lua");
|
|
278
|
+
const outputPath = path.join(dir, "mcp_output.txt");
|
|
279
|
+
const requestId = randomUUID();
|
|
280
|
+
const stampedCode = `-- refmcp_request_id: ${requestId}\n${code}`;
|
|
281
|
+
const deadline = Date.now() + POLL_TIMEOUT;
|
|
282
|
+
for (;;) {
|
|
283
|
+
// 1. Write the Lua code to the input file.
|
|
284
|
+
try {
|
|
285
|
+
await fs.writeFile(inputPath, stampedCode, "utf8");
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (err.code === "ENOENT") {
|
|
289
|
+
throw new Error(`Data directory "${dir}" does not exist. ` +
|
|
290
|
+
`Call set_data_dir again with a valid path (or install_bridge).`);
|
|
291
|
+
}
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
// 2. Wait for the bridge to blank the file (signals execution done).
|
|
295
|
+
let consumed = false;
|
|
296
|
+
while (Date.now() < deadline) {
|
|
297
|
+
await sleep(POLL_INTERVAL);
|
|
298
|
+
const content = await readSafe(inputPath);
|
|
299
|
+
if (content.trim() === "") {
|
|
300
|
+
consumed = true;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!consumed) {
|
|
305
|
+
// Clear the stale input so a bridge starting later does not execute it.
|
|
306
|
+
await fs.writeFile(inputPath, "", "utf8").catch(() => undefined);
|
|
307
|
+
throw await buildTimeoutError(inputPath);
|
|
308
|
+
}
|
|
309
|
+
// 3. Read the output and make sure it belongs to this request. If the
|
|
310
|
+
// bridge consumed a stale script it wipes our input before it runs, so on
|
|
311
|
+
// a request-id mismatch write our code again and keep waiting. Output
|
|
312
|
+
// without a request id comes from an older bridge and is accepted as-is.
|
|
313
|
+
const output = await readSafe(outputPath);
|
|
314
|
+
const outputId = output.match(REQUEST_ID_PATTERN)?.[1];
|
|
315
|
+
if (outputId === undefined || outputId === requestId) {
|
|
316
|
+
return output.trim() === ""
|
|
317
|
+
? "(no output written – check the REFramework logs)"
|
|
318
|
+
: output;
|
|
319
|
+
}
|
|
320
|
+
process.stderr.write(`[reframework-mcp] warning: bridge consumed a stale script (output request ${outputId}); retrying\n`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function installBridge(input) {
|
|
324
|
+
let searchedFrom;
|
|
325
|
+
if (input) {
|
|
326
|
+
const abs = path.resolve(input);
|
|
327
|
+
if (!(await isDir(abs)) && !(await isFile(abs))) {
|
|
328
|
+
throw new Error(`"${abs}" does not exist. Pass the game install directory (the folder containing ` +
|
|
329
|
+
`the game .exe), the reframework folder, or a path inside them.`);
|
|
330
|
+
}
|
|
331
|
+
searchedFrom = abs;
|
|
332
|
+
}
|
|
333
|
+
else if (dataDir) {
|
|
334
|
+
searchedFrom = dataDir;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
throw new Error("Where is the game installed? Pass the game install directory, e.g. " +
|
|
338
|
+
`install_bridge with path "C:\\Games\\RE9".`);
|
|
339
|
+
}
|
|
340
|
+
const gameRoot = await findGameRoot(searchedFrom);
|
|
341
|
+
if (!gameRoot) {
|
|
342
|
+
const startMissing = !(await isDir(searchedFrom)) && !(await isFile(searchedFrom));
|
|
343
|
+
throw new Error((startMissing
|
|
344
|
+
? `"${searchedFrom}" does not exist, and no "reframework" folder was found at or ` +
|
|
345
|
+
`above it.\n`
|
|
346
|
+
: `REFramework does not appear to be installed at or above "${searchedFrom}".\n`) +
|
|
347
|
+
`I looked for a "reframework" folder in the game install directory (next to the ` +
|
|
348
|
+
`game .exe) and its parents.\n` +
|
|
349
|
+
`Install REFramework first: download a release, then extract dinput8.dll and the ` +
|
|
350
|
+
`reframework folder into the game install directory.\n` +
|
|
351
|
+
` https://github.com/praydog/REFramework-nightly/releases`);
|
|
352
|
+
}
|
|
353
|
+
const refDir = path.join(gameRoot, "reframework");
|
|
354
|
+
const autorunDir = path.join(refDir, "autorun");
|
|
355
|
+
const targetPath = path.join(autorunDir, "mcp.lua");
|
|
356
|
+
const source = await findLuaSource();
|
|
357
|
+
if (!source) {
|
|
358
|
+
throw new Error("Could not find the bundled mcp.lua bridge source.\nLooked in:\n" +
|
|
359
|
+
luaSourceCandidates().map((candidate) => ` ${candidate}`).join("\n") +
|
|
360
|
+
"\nRebuild the server (npm run build) or set REF_MCP_LUA to its path.");
|
|
361
|
+
}
|
|
362
|
+
// Install (or refresh) the bridge script.
|
|
363
|
+
await fs.mkdir(autorunDir, { recursive: true });
|
|
364
|
+
const sourceCode = await fs.readFile(source, "utf8");
|
|
365
|
+
const previous = await readSafe(targetPath);
|
|
366
|
+
let action;
|
|
367
|
+
if (previous === sourceCode) {
|
|
368
|
+
action = "already up to date";
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
await fs.writeFile(targetPath, sourceCode, "utf8");
|
|
372
|
+
action = previous ? "updated" : "copied";
|
|
373
|
+
}
|
|
374
|
+
// Make sure the data directory exists and activate it.
|
|
375
|
+
const gameDataDir = path.join(refDir, "data");
|
|
376
|
+
await fs.mkdir(gameDataDir, { recursive: true });
|
|
377
|
+
dataDir = gameDataDir;
|
|
378
|
+
const persisted = await persistDataDir(gameDataDir);
|
|
379
|
+
const exe = await findGameExe(gameRoot);
|
|
380
|
+
const lines = [
|
|
381
|
+
`Lua bridge ${action}.`,
|
|
382
|
+
` game install : ${gameRoot}`,
|
|
383
|
+
];
|
|
384
|
+
if (exe)
|
|
385
|
+
lines.push(` game exe : ${exe}`);
|
|
386
|
+
lines.push(` bridge file : ${targetPath}`, ` source : ${source}`, ` data dir : ${gameDataDir} (active)`);
|
|
387
|
+
if (envDataDir && !samePath(envDataDir, gameDataDir)) {
|
|
388
|
+
lines.push(` note : REF_DATA_DIR=${envDataDir} takes over again after a restart`);
|
|
389
|
+
}
|
|
390
|
+
else if (persisted) {
|
|
391
|
+
lines.push(` note : saved to ${CONFIG_PATH}; future server starts use it automatically`);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
lines.push(`Warning: could not save the active data dir to ${CONFIG_PATH}; it will not survive a restart.`);
|
|
395
|
+
}
|
|
396
|
+
if (!(await isFile(path.join(gameRoot, "dinput8.dll")))) {
|
|
397
|
+
lines.push(`Warning: no dinput8.dll found next to the game .exe. If REFramework is not set up ` +
|
|
398
|
+
`correctly the bridge will never load.`);
|
|
399
|
+
}
|
|
400
|
+
if (action === "already up to date") {
|
|
401
|
+
lines.push("The bridge is already current. Use execute_code once the game is running.");
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
lines.push("ACTION REQUIRED: Tell the user the Lua bridge was just installed/updated and they must " +
|
|
405
|
+
"reload the Lua plugins in-game before execute_code will work (open the REFramework menu " +
|
|
406
|
+
"and use ScriptRunner -> Reset Scripts, or simply restart the game). " +
|
|
407
|
+
"The bridge is only loaded on a script reload.");
|
|
408
|
+
}
|
|
409
|
+
return lines.join("\n");
|
|
410
|
+
}
|
|
411
|
+
function samePath(a, b) {
|
|
412
|
+
const na = path.resolve(a);
|
|
413
|
+
const nb = path.resolve(b);
|
|
414
|
+
return process.platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
415
|
+
}
|
|
416
|
+
async function setDataDir(input) {
|
|
417
|
+
const dir = await resolveDataDir(input);
|
|
418
|
+
dataDir = dir;
|
|
419
|
+
const persisted = await persistDataDir(dir);
|
|
420
|
+
const lines = [`Data directory set to ${dir}.`, await describeDataDir(dir)];
|
|
421
|
+
if (envDataDir) {
|
|
422
|
+
lines.push(`Note: REF_DATA_DIR is set to ${envDataDir} and will take over again after a restart ` +
|
|
423
|
+
`(update the env value to make this permanent).`);
|
|
424
|
+
}
|
|
425
|
+
else if (persisted) {
|
|
426
|
+
lines.push(`Saved to ${CONFIG_PATH} - future server starts use it automatically.`);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
lines.push(`Warning: could not save the choice to ${CONFIG_PATH}; it will not survive a restart.`);
|
|
430
|
+
}
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
// ── server setup ──────────────────────────────────────────────────────────────
|
|
434
|
+
const server = new McpServer({
|
|
435
|
+
name: "reframework",
|
|
436
|
+
version: "1.1.0",
|
|
437
|
+
});
|
|
438
|
+
server.tool("execute_code", "Execute Lua code inside a running RE Engine game via REFramework. " +
|
|
439
|
+
"The code runs in the game process and has full access to REFramework APIs " +
|
|
440
|
+
"(sdk, re, log, fs, json, imgui, draw). " +
|
|
441
|
+
"Use print() to produce output; the final return value is also captured. " +
|
|
442
|
+
"Requires mcp.lua installed in the game's reframework/autorun folder and a configured " +
|
|
443
|
+
"data directory (use install_bridge or set_data_dir if needed).", {
|
|
444
|
+
code: z
|
|
445
|
+
.string()
|
|
446
|
+
.min(1)
|
|
447
|
+
.describe("Lua source code to execute inside the game"),
|
|
448
|
+
}, async ({ code }) => {
|
|
449
|
+
try {
|
|
450
|
+
const output = await enqueue(() => executeCode(code));
|
|
451
|
+
return {
|
|
452
|
+
content: [{ type: "text", text: output }],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
457
|
+
return {
|
|
458
|
+
content: [{ type: "text", text: `[MCP error] ${message}` }],
|
|
459
|
+
isError: true,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
server.tool("set_data_dir", "Set the reframework/data directory used by execute_code. Call this when REFramework is " +
|
|
464
|
+
"already installed but the server was started without REF_DATA_DIR. Accepts the game " +
|
|
465
|
+
"install directory (e.g. C:\\Games\\RE9), the reframework directory, or the data " +
|
|
466
|
+
"directory itself. The choice is remembered for future server starts unless REF_DATA_DIR " +
|
|
467
|
+
"is set.", {
|
|
468
|
+
path: z
|
|
469
|
+
.string()
|
|
470
|
+
.min(1)
|
|
471
|
+
.describe("Game install directory, reframework directory, or reframework/data directory (absolute path)"),
|
|
472
|
+
}, async ({ path: input }) => {
|
|
473
|
+
try {
|
|
474
|
+
const output = await enqueue(() => setDataDir(input));
|
|
475
|
+
return {
|
|
476
|
+
content: [{ type: "text", text: output }],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: "text", text: `[MCP error] ${message}` }],
|
|
483
|
+
isError: true,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
server.tool("install_bridge", "Install the REFramework Lua bridge (mcp.lua) into a game so execute_code can run. " +
|
|
488
|
+
"Copies the bundled mcp.lua to <game>\\reframework\\autorun\\mcp.lua, creates the " +
|
|
489
|
+
"<game>\\reframework\\data directory and makes it the active data directory. " +
|
|
490
|
+
"Accepts the game install directory (e.g. C:\\Games\\RE9), the reframework directory, " +
|
|
491
|
+
"or a path inside them; when omitted, the currently configured data directory is used. " +
|
|
492
|
+
"Fails with instructions if REFramework is not installed at the given location. " +
|
|
493
|
+
"When the bridge is copied or updated, tell the user to reload the Lua scripts in-game " +
|
|
494
|
+
"(or restart the game) so the new bridge actually loads.", {
|
|
495
|
+
path: z
|
|
496
|
+
.string()
|
|
497
|
+
.min(1)
|
|
498
|
+
.optional()
|
|
499
|
+
.describe("Game install directory, reframework directory, or a path inside them (absolute path)"),
|
|
500
|
+
}, async ({ path: input }) => {
|
|
501
|
+
try {
|
|
502
|
+
const output = await enqueue(() => installBridge(input));
|
|
503
|
+
return {
|
|
504
|
+
content: [{ type: "text", text: output }],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: "text", text: `[MCP error] ${message}` }],
|
|
511
|
+
isError: true,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
516
|
+
async function main() {
|
|
517
|
+
const transport = new StdioServerTransport();
|
|
518
|
+
await server.connect(transport);
|
|
519
|
+
// MCP servers communicate over stdio; log to stderr so it doesn't corrupt the stream.
|
|
520
|
+
process.stderr.write(`[reframework-mcp] ready\n` +
|
|
521
|
+
` data dir : ${dataDir ?? "(not set - use the set_data_dir or install_bridge tool)"}\n` +
|
|
522
|
+
` config : ${CONFIG_PATH}\n` +
|
|
523
|
+
` poll : every ${POLL_INTERVAL} ms, timeout ${POLL_TIMEOUT} ms\n`);
|
|
524
|
+
if (dataDir && !(await isDir(dataDir))) {
|
|
525
|
+
process.stderr.write(`[reframework-mcp] warning: data dir ${dataDir} does not exist yet\n`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
main().catch((err) => {
|
|
529
|
+
process.stderr.write(`[reframework-mcp] fatal: ${err}\n`);
|
|
530
|
+
process.exit(1);
|
|
531
|
+
});
|
package/dist/mcp.lua
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
-- mcp.lua
|
|
2
|
+
-- Place in: reframework/autorun/mcp.lua
|
|
3
|
+
--
|
|
4
|
+
-- Polls for reframework/data/mcp_input.lua every second.
|
|
5
|
+
-- When found, executes its contents in a sandboxed environment,
|
|
6
|
+
-- captures print() output + return values + errors,
|
|
7
|
+
-- writes everything to reframework/data/mcp_output.txt,
|
|
8
|
+
-- then blanks the input file so it doesn't re-run.
|
|
9
|
+
--
|
|
10
|
+
-- The input may carry a "refmcp_request_id" marker (first line, written by the
|
|
11
|
+
-- MCP server). It is echoed into the output header so the server can match the
|
|
12
|
+
-- output to the request that produced it.
|
|
13
|
+
|
|
14
|
+
local INPUT_FILE = "mcp_input.lua"
|
|
15
|
+
local OUTPUT_FILE = "mcp_output.txt"
|
|
16
|
+
local CHECK_INTERVAL = 60 -- frames between polls (~1 s at 60 fps)
|
|
17
|
+
|
|
18
|
+
local frame_count = 0
|
|
19
|
+
|
|
20
|
+
-- ── helpers ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
-- Serialize any value to a human-readable string (handles tables recursively).
|
|
23
|
+
local function serialize(val, depth)
|
|
24
|
+
depth = depth or 0
|
|
25
|
+
local indent = string.rep(" ", depth)
|
|
26
|
+
|
|
27
|
+
local t = type(val)
|
|
28
|
+
if t == "nil" then
|
|
29
|
+
return "nil"
|
|
30
|
+
elseif t == "boolean" or t == "number" then
|
|
31
|
+
return tostring(val)
|
|
32
|
+
elseif t == "string" then
|
|
33
|
+
return string.format("%q", val)
|
|
34
|
+
elseif t == "table" then
|
|
35
|
+
if depth > 4 then return "{...}" end -- guard against deep nesting
|
|
36
|
+
local parts = {}
|
|
37
|
+
for k, v in pairs(val) do
|
|
38
|
+
local key_str = (type(k) == "string")
|
|
39
|
+
and k
|
|
40
|
+
or ("[" .. tostring(k) .. "]")
|
|
41
|
+
parts[#parts + 1] = indent .. " " .. key_str .. " = " .. serialize(v, depth + 1)
|
|
42
|
+
end
|
|
43
|
+
if #parts == 0 then return "{}" end
|
|
44
|
+
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}"
|
|
45
|
+
else
|
|
46
|
+
-- userdata, function, thread, etc.
|
|
47
|
+
return "<" .. t .. ": " .. tostring(val) .. ">"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
-- Extract the request id marker written by the MCP server, if present.
|
|
52
|
+
local function get_request_id(source)
|
|
53
|
+
return source:match("^%-%- refmcp_request_id: ([%w%-]+)") or "none"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
-- Run a Lua source string in a restricted env, return {output, error}.
|
|
57
|
+
local function execute_script(source)
|
|
58
|
+
local captured_lines = {}
|
|
59
|
+
|
|
60
|
+
-- Sandboxed environment that shares the REFramework globals but
|
|
61
|
+
-- redirects print() so we can capture it.
|
|
62
|
+
local env = setmetatable({
|
|
63
|
+
print = function(...)
|
|
64
|
+
local args = { ... }
|
|
65
|
+
local parts = {}
|
|
66
|
+
for i = 1, #args do
|
|
67
|
+
parts[i] = serialize(args[i])
|
|
68
|
+
end
|
|
69
|
+
captured_lines[#captured_lines + 1] = table.concat(parts, "\t")
|
|
70
|
+
end,
|
|
71
|
+
tostring = tostring,
|
|
72
|
+
tonumber = tonumber,
|
|
73
|
+
type = type,
|
|
74
|
+
pairs = pairs,
|
|
75
|
+
ipairs = ipairs,
|
|
76
|
+
next = next,
|
|
77
|
+
select = select,
|
|
78
|
+
unpack = table.unpack or unpack,
|
|
79
|
+
error = error,
|
|
80
|
+
pcall = pcall,
|
|
81
|
+
xpcall = xpcall,
|
|
82
|
+
assert = assert,
|
|
83
|
+
string = string,
|
|
84
|
+
table = table,
|
|
85
|
+
math = math,
|
|
86
|
+
-- Expose REFramework APIs so scripts can actually use them:
|
|
87
|
+
sdk = sdk,
|
|
88
|
+
re = re,
|
|
89
|
+
log = log,
|
|
90
|
+
fs = fs,
|
|
91
|
+
json = json,
|
|
92
|
+
imgui = imgui,
|
|
93
|
+
draw = draw,
|
|
94
|
+
-- Expose full _G for power users (they can break their own sandbox).
|
|
95
|
+
_G = _G,
|
|
96
|
+
}, { __index = _G })
|
|
97
|
+
|
|
98
|
+
local chunk, load_err = load(source, "mcp_input", "t", env)
|
|
99
|
+
if not chunk then
|
|
100
|
+
return nil, "Syntax/load error: " .. tostring(load_err)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local ok, result = pcall(chunk)
|
|
104
|
+
|
|
105
|
+
local output_text = ""
|
|
106
|
+
if #captured_lines > 0 then
|
|
107
|
+
output_text = table.concat(captured_lines, "\n") .. "\n"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if not ok then
|
|
111
|
+
return output_text, "Runtime error: " .. tostring(result)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
-- If the chunk returned a value, append it.
|
|
115
|
+
if result ~= nil then
|
|
116
|
+
output_text = output_text .. "=> " .. serialize(result) .. "\n"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return output_text, nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
-- ── main poll loop ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
re.on_frame(function()
|
|
125
|
+
frame_count = frame_count + 1
|
|
126
|
+
if frame_count < CHECK_INTERVAL then return end
|
|
127
|
+
frame_count = 0
|
|
128
|
+
|
|
129
|
+
-- Try to read the input file.
|
|
130
|
+
local source = fs.read(INPUT_FILE)
|
|
131
|
+
if source == nil or source == "" then return end
|
|
132
|
+
|
|
133
|
+
log.debug("[mcp] mcp_input.lua detected - executing...")
|
|
134
|
+
|
|
135
|
+
-- Build the output header (echoing the request id back to the server).
|
|
136
|
+
local req_id = get_request_id(source)
|
|
137
|
+
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
|
|
138
|
+
local header = "-- refmcp_request_id: " .. req_id .. "\n"
|
|
139
|
+
.. "-- mcp_output.txt | executed: " .. timestamp .. "\n"
|
|
140
|
+
.. string.rep("-", 60) .. "\n"
|
|
141
|
+
|
|
142
|
+
local output, err = execute_script(source)
|
|
143
|
+
|
|
144
|
+
local body = ""
|
|
145
|
+
if output and output ~= "" then
|
|
146
|
+
body = body .. output
|
|
147
|
+
end
|
|
148
|
+
if err then
|
|
149
|
+
body = body .. "\n[ERROR]\n" .. err .. "\n"
|
|
150
|
+
end
|
|
151
|
+
if body == "" then
|
|
152
|
+
body = "(script produced no output)\n"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
local full_output = header .. body
|
|
156
|
+
|
|
157
|
+
-- Write result.
|
|
158
|
+
fs.write(OUTPUT_FILE, full_output)
|
|
159
|
+
log.debug("[mcp] wrote " .. OUTPUT_FILE)
|
|
160
|
+
|
|
161
|
+
-- Erase the input file so it won't re-run next poll.
|
|
162
|
+
fs.write(INPUT_FILE, "")
|
|
163
|
+
|
|
164
|
+
log.debug("[mcp] done.")
|
|
165
|
+
end)
|
|
166
|
+
|
|
167
|
+
log.debug("[mcp] loaded - polling every " .. CHECK_INTERVAL .. " frames for " .. INPUT_FILE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@biorand/reframework-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for running Lua inside RE Engine games via REFramework",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc && node scripts/copy-lua.mjs",
|
|
8
|
+
"start": "node dist/index.js",
|
|
9
|
+
"prepack": "npm run build"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"mcp",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"reframework",
|
|
15
|
+
"re-engine",
|
|
16
|
+
"modding"
|
|
17
|
+
],
|
|
18
|
+
"author": "biorand",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/biorand/reframework-mcp.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/biorand/reframework-mcp#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/biorand/reframework-mcp/issues"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
|
+
"zod": "^4.4.3"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^25.9.1",
|
|
43
|
+
"typescript": "^6.0.3"
|
|
44
|
+
},
|
|
45
|
+
"type": "module",
|
|
46
|
+
"bin": {
|
|
47
|
+
"reframework-mcp": "dist/index.js"
|
|
48
|
+
}
|
|
49
|
+
}
|