@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 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
- # Temporary Holding Version
1
+ # @biorand/reframework-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }