roforge-cli 0.3.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/README.md +58 -0
- package/bin/roforge.js +384 -0
- package/demo/e2e-demo.mjs +53 -0
- package/package.json +44 -0
- package/src/agent.js +137 -0
- package/src/bridge/server.js +154 -0
- package/src/bridge/wire.js +10 -0
- package/src/config.js +227 -0
- package/src/mcp.js +159 -0
- package/src/providers/anthropic.js +161 -0
- package/src/providers/gemini.js +141 -0
- package/src/providers/groq.js +16 -0
- package/src/providers/openai.js +138 -0
- package/src/providers/openrouter.js +17 -0
- package/src/session.js +192 -0
- package/src/tools/index.js +49 -0
- package/src/tools/project.js +212 -0
- package/src/tools/roblox.js +95 -0
- package/src/tools/studio.js +296 -0
- package/src/tools/web.js +155 -0
- package/src/tui/ansi.js +41 -0
- package/src/tui/markdown.js +67 -0
- package/src/tui/tui.js +463 -0
- package/src/util.js +117 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Tool registry: merges local tools (web, roblox, project) with the active
|
|
2
|
+
// Studio tier (bridge or MCP). Same contract everywhere:
|
|
3
|
+
// { name, description, inputSchema, execute(args, ctx) -> string, requiresApproval? }
|
|
4
|
+
// Errors are returned as "ERROR: ..." strings the model can adapt to.
|
|
5
|
+
import { webTools } from "./web.js";
|
|
6
|
+
import { robloxTools } from "./roblox.js";
|
|
7
|
+
import { projectTools } from "./project.js";
|
|
8
|
+
import { bridgeTools, mcpToolsFromList, mcpCaptureNames } from "./studio.js";
|
|
9
|
+
import { McpClient } from "../mcp.js";
|
|
10
|
+
|
|
11
|
+
export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
|
|
12
|
+
const tools = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const add = (list) => {
|
|
15
|
+
for (const t of list) {
|
|
16
|
+
if (!t || seen.has(t.name)) continue;
|
|
17
|
+
seen.add(t.name);
|
|
18
|
+
tools.push(t);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
add(webTools());
|
|
23
|
+
add(robloxTools());
|
|
24
|
+
add(projectTools({ cwd, luauAnalyzePath }));
|
|
25
|
+
if (bridgeServer) add(bridgeTools(bridgeServer));
|
|
26
|
+
|
|
27
|
+
// MCP tier (official, built into Studio)
|
|
28
|
+
if (cfg.studioMode === "mcp" || cfg.studioMode === "auto") {
|
|
29
|
+
try {
|
|
30
|
+
const client = new McpClient(cfg.mcpUrl, { timeoutMs: cfg.studioMode === "mcp" ? 30000 : 4000 });
|
|
31
|
+
await client.connect();
|
|
32
|
+
const raw = await client.listTools();
|
|
33
|
+
if (raw.length) {
|
|
34
|
+
add(mcpToolsFromList(client, raw));
|
|
35
|
+
cfg._mcpClient = client;
|
|
36
|
+
cfg._mcpConnected = true;
|
|
37
|
+
return { tools, mcp: true, bridge: Boolean(bridgeServer), mcpToolCount: raw.length, mcpCapture: mcpCaptureNames(raw) };
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
/* fall through to bridge-only */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { tools, mcp: false, bridge: Boolean(bridgeServer), mcpToolCount: 0, mcpCapture: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listToolNames(tools) {
|
|
48
|
+
return tools.map((t) => t.name);
|
|
49
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Project tools: work on the Rojo project on disk (the Claude-Code-style
|
|
2
|
+
// "agent edits your project files" workflow). cwd-scoped, with path guards.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { truncate } from "../util.js";
|
|
7
|
+
|
|
8
|
+
const IGNORED = new Set([
|
|
9
|
+
"node_modules", ".git", "dist", "build", ".rojo", "coverage", ".venv", "out", "target", "__pycache__", ".next",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function isWithin(root, p) {
|
|
13
|
+
const rel = path.relative(root, p);
|
|
14
|
+
return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeJoin(root, p) {
|
|
18
|
+
const abs = path.resolve(root, p);
|
|
19
|
+
if (!isWithin(root, abs)) throw new Error(`path escapes the project root: ${p}`);
|
|
20
|
+
return abs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function listTree(root, dir = "", depth = 0, maxDepth = 4, acc = [], cap = { n: 0 }) {
|
|
24
|
+
if (depth > maxDepth || cap.n > 500) return acc;
|
|
25
|
+
const entries = fs.readdirSync(path.join(root, dir), { withFileTypes: true })
|
|
26
|
+
.filter((e) => !IGNORED.has(e.name) && !e.name.startsWith(".rojo"))
|
|
27
|
+
.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
|
|
28
|
+
for (const e of entries) {
|
|
29
|
+
if (cap.n++ > 500) {
|
|
30
|
+
acc.push(`${" ".repeat(depth)}… (truncated)`);
|
|
31
|
+
return acc;
|
|
32
|
+
}
|
|
33
|
+
acc.push(`${" ".repeat(depth)}${e.name}${e.isDirectory() ? "/" : ""}`);
|
|
34
|
+
if (e.isDirectory()) listTree(root, path.join(dir, e.name), depth + 1, maxDepth, acc, cap);
|
|
35
|
+
}
|
|
36
|
+
return acc;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function projectTools({ cwd, luauAnalyzePath }) {
|
|
40
|
+
const root = path.resolve(cwd);
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
name: "project_tree",
|
|
44
|
+
description: `List the Rojo project tree on disk (root: ${root}). Use before reading or editing files.`,
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
dir: { type: "string", description: "Subdirectory. Default: project root." },
|
|
49
|
+
max_depth: { type: "integer", description: "1-8. Default 4." },
|
|
50
|
+
},
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
},
|
|
53
|
+
execute: async (args) => {
|
|
54
|
+
const dir = args.dir ? safeJoin(root, args.dir) : root;
|
|
55
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new Error(`not a directory: ${args.dir}`);
|
|
56
|
+
return listTree(root, dir === root ? "" : path.relative(root, dir), 0, Math.min(Math.max(1, Number(args.max_depth) || 4), 8)).join("\n");
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "project_read",
|
|
61
|
+
description: "Read a file from the project (relative path).",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: { path: { type: "string" } },
|
|
65
|
+
required: ["path"],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
execute: async (args) => {
|
|
69
|
+
const abs = safeJoin(root, args.path);
|
|
70
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw new Error(`no such file: ${args.path}`);
|
|
71
|
+
const src = fs.readFileSync(abs, "utf8");
|
|
72
|
+
return truncate(`-- ${args.path} (${src.length} chars)\n${src}`, 24000);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "project_write",
|
|
77
|
+
description:
|
|
78
|
+
"Write (create or overwrite) a file in the project. ALWAYS send the complete new file content, never partial edits.",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
path: { type: "string" },
|
|
83
|
+
content: { type: "string" },
|
|
84
|
+
},
|
|
85
|
+
required: ["path", "content"],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
requiresApproval: true,
|
|
89
|
+
execute: async (args) => {
|
|
90
|
+
const abs = safeJoin(root, args.path);
|
|
91
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
92
|
+
fs.writeFileSync(abs, String(args.content));
|
|
93
|
+
return `Wrote ${String(args.content).length} chars to ${args.path}`;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "project_edit",
|
|
98
|
+
description:
|
|
99
|
+
"Replace an exact text match in a file (first occurrence). Safer than project_write for small changes; old_text must match exactly (whitespace included).",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: {
|
|
103
|
+
path: { type: "string" },
|
|
104
|
+
old_text: { type: "string" },
|
|
105
|
+
new_text: { type: "string" },
|
|
106
|
+
},
|
|
107
|
+
required: ["path", "old_text", "new_text"],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
requiresApproval: true,
|
|
111
|
+
execute: async (args) => {
|
|
112
|
+
const abs = safeJoin(root, args.path);
|
|
113
|
+
const src = fs.readFileSync(abs, "utf8");
|
|
114
|
+
const idx = src.indexOf(args.old_text);
|
|
115
|
+
if (idx === -1) throw new Error("old_text not found — read the file and retry with the exact text");
|
|
116
|
+
if (src.indexOf(args.old_text, idx + 1) !== -1) {
|
|
117
|
+
throw new Error("old_text is not unique in the file — include more surrounding context");
|
|
118
|
+
}
|
|
119
|
+
fs.writeFileSync(abs, src.slice(0, idx) + args.new_text + src.slice(idx + args.old_text.length));
|
|
120
|
+
return `Edited ${args.path}`;
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "project_search",
|
|
125
|
+
description: "Grep the project files (case-insensitive fixed-string search). Returns file:line matches.",
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
pattern: { type: "string", description: "Fixed string to search for" },
|
|
130
|
+
glob: { type: "string", description: "Only search files whose name contains this, e.g. 'lua'" },
|
|
131
|
+
},
|
|
132
|
+
required: ["pattern"],
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
},
|
|
135
|
+
execute: async (args) => {
|
|
136
|
+
const needle = String(args.pattern).toLowerCase();
|
|
137
|
+
const matches = [];
|
|
138
|
+
const cap = 100;
|
|
139
|
+
const walk = (dir) => {
|
|
140
|
+
if (matches.length >= cap) return;
|
|
141
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
142
|
+
if (matches.length >= cap) return;
|
|
143
|
+
if (IGNORED.has(e.name) || e.name.startsWith(".")) continue;
|
|
144
|
+
const p = path.join(dir, e.name);
|
|
145
|
+
if (e.isDirectory()) walk(p);
|
|
146
|
+
else {
|
|
147
|
+
if (args.glob && !e.name.toLowerCase().includes(args.glob.toLowerCase())) continue;
|
|
148
|
+
if (p.length > 300 || !/\.(lua|md|json|txt|rbxl)$/.test(e.name)) continue;
|
|
149
|
+
try {
|
|
150
|
+
const lines = fs.readFileSync(p, "utf8").split("\n");
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
if (matches.length >= cap) return;
|
|
153
|
+
if (lines[i].toLowerCase().includes(needle)) {
|
|
154
|
+
matches.push(`${path.relative(root, p)}:${i + 1}: ${lines[i].trim().slice(0, 160)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* skip unreadable */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
walk(root);
|
|
164
|
+
return matches.length ? matches.join("\n") : `No matches for '${args.pattern}'.`;
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "project_run",
|
|
169
|
+
description:
|
|
170
|
+
"Run a command in the project root (e.g. 'rojo build -o dist/RoForge.rbxm', 'node --test test/', 'luau-analyze src/…'). 60s timeout. Use for builds and checks, not long-running servers.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: { command: { type: "string", description: "Shell command to run" } },
|
|
174
|
+
required: ["command"],
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
},
|
|
177
|
+
requiresApproval: true,
|
|
178
|
+
execute: async (args) => {
|
|
179
|
+
const { code, out } = await runShell(String(args.command), root, 60000);
|
|
180
|
+
const status = code === 0 ? "OK" : `exit ${code}`;
|
|
181
|
+
return `[${args.command}]\n${status}\n${truncate(out, 8000)}`;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
luauAnalyzePath
|
|
185
|
+
? {
|
|
186
|
+
name: "luau_analyze",
|
|
187
|
+
description: `Run the official Luau static analyzer on a project file (catches syntax + type errors). Path relative to project root.`,
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: { path: { type: "string" } },
|
|
191
|
+
required: ["path"],
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
execute: async (args) => {
|
|
195
|
+
const abs = safeJoin(root, args.path);
|
|
196
|
+
if (!fs.existsSync(abs)) throw new Error(`no such file: ${args.path}`);
|
|
197
|
+
const { code, out } = await runShell(`"${luauAnalyzePath}" "${abs}"`, root, 30000);
|
|
198
|
+
return code === 0 ? "No analyzer issues found." : truncate(out, 6000);
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
: null,
|
|
202
|
+
].filter(Boolean);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function runShell(command, cwd, timeoutMs) {
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
execFile("/bin/sh", ["-c", command], { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
208
|
+
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
|
209
|
+
resolve({ code, out: [stdout, stderr].filter(Boolean).join("\n") || "(no output)" });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Roblox API tools, run locally against official public endpoints.
|
|
2
|
+
const UA = "RoForge/0.2 (+local; roblox studio agent)";
|
|
3
|
+
|
|
4
|
+
async function robloxGet(url, timeoutMs = 10000) {
|
|
5
|
+
const res = await fetch(url, {
|
|
6
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
7
|
+
headers: { "User-Agent": UA, Accept: "application/json" },
|
|
8
|
+
}).catch((e) => {
|
|
9
|
+
throw new Error(`Roblox API unreachable: ${e.cause?.code || e.message}`);
|
|
10
|
+
});
|
|
11
|
+
if (res.status === 403) {
|
|
12
|
+
throw new Error("Roblox API refused the request (403) — commonly from datacenter IPs. Retry later.");
|
|
13
|
+
}
|
|
14
|
+
if (!res.ok) throw new Error(`Roblox API HTTP ${res.status}`);
|
|
15
|
+
return res.json();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function positiveInt(v, field) {
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${field} must be a positive integer`);
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function gameByUniverse(universeId) {
|
|
25
|
+
const n = positiveInt(universeId, "universeId");
|
|
26
|
+
const data = await robloxGet(`https://games.roblox.com/v1/games?universeIds=${n}`);
|
|
27
|
+
const g = (data.data || [])[0];
|
|
28
|
+
if (!g) return `No game found for universeId ${n}.`;
|
|
29
|
+
return [
|
|
30
|
+
`Game: ${g.name}`,
|
|
31
|
+
` gameId: ${g.id}`,
|
|
32
|
+
` universeId: ${g.universeId}`,
|
|
33
|
+
` playing: ${g.playing}`,
|
|
34
|
+
` visits: ${g.visits}`,
|
|
35
|
+
` maxPlayers: ${g.maxPlayers}`,
|
|
36
|
+
` updated: ${g.updated}`,
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function gameByPlace(placeId) {
|
|
41
|
+
const n = positiveInt(placeId, "placeId");
|
|
42
|
+
const data = await robloxGet(`https://apis.roblox.com/universes/v1/places/${n}/universe`);
|
|
43
|
+
const uid = data && data.universeId;
|
|
44
|
+
if (!uid) return `No universe found for placeId ${n}.`;
|
|
45
|
+
return `placeId ${n} → universeId ${uid}\n\n${await gameByUniverse(uid)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function userLookup(usernames) {
|
|
49
|
+
if (!Array.isArray(usernames) || !usernames.length) throw new Error("usernames must be a non-empty array");
|
|
50
|
+
const list = usernames.slice(0, 10).map((u) => String(u).trim()).filter(Boolean);
|
|
51
|
+
const data = await robloxGet(`https://users.roblox.com/v1/users?userNames=${encodeURIComponent(list.join(","))}`);
|
|
52
|
+
const users = data.data || [];
|
|
53
|
+
if (!users.length) return `No users found for: ${list.join(", ")}`;
|
|
54
|
+
return users
|
|
55
|
+
.map((u) => `User: ${u.name}\n id: ${u.id}\n displayName: ${u.displayName}\n createdAt: ${u.created}`)
|
|
56
|
+
.join("\n\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function robloxTools() {
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
name: "roblox_game_lookup",
|
|
63
|
+
description: "Look up a Roblox game by universeId. Returns name, id, players, visits.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: { universeId: { type: "integer" } },
|
|
67
|
+
required: ["universeId"],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
execute: async (args) => gameByUniverse(args.universeId),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "roblox_game_by_place",
|
|
74
|
+
description: "Look up a Roblox game by placeId (the number in the Studio place URL).",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: { placeId: { type: "integer" } },
|
|
78
|
+
required: ["placeId"],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
execute: async (args) => gameByPlace(args.placeId),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "roblox_user_lookup",
|
|
85
|
+
description: "Look up Roblox users by username (1-10). Returns ids, display names, created dates.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: { usernames: { type: "array", items: { type: "string" } } },
|
|
89
|
+
required: ["usernames"],
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
},
|
|
92
|
+
execute: async (args) => userLookup(args.usernames),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Studio tools — two interchangeable tiers:
|
|
2
|
+
// • bridge: our RoForge Bridge plugin polls the local BridgeServer
|
|
3
|
+
// • mcp: the MCP server built into Roblox Studio (beta), over HTTP
|
|
4
|
+
// Both expose the same tool shape; the agent doesn't care which tier answers.
|
|
5
|
+
|
|
6
|
+
const BRIDGE_TOOL_NAMES = [
|
|
7
|
+
"forge_selected",
|
|
8
|
+
"forge_tree",
|
|
9
|
+
"forge_read",
|
|
10
|
+
"forge_write",
|
|
11
|
+
"forge_create",
|
|
12
|
+
"forge_delete",
|
|
13
|
+
"forge_run",
|
|
14
|
+
"forge_screenshot",
|
|
15
|
+
"forge_game_info",
|
|
16
|
+
"forge_viewport",
|
|
17
|
+
"forge_get_property",
|
|
18
|
+
"forge_set_property",
|
|
19
|
+
"forge_get_attributes",
|
|
20
|
+
"forge_set_attribute",
|
|
21
|
+
"forge_select",
|
|
22
|
+
"forge_checkpoint",
|
|
23
|
+
"forge_undo",
|
|
24
|
+
"forge_checkpoints",
|
|
25
|
+
"forge_find",
|
|
26
|
+
"forge_bulk_create",
|
|
27
|
+
"forge_snapshot",
|
|
28
|
+
"forge_diff",
|
|
29
|
+
"forge_export",
|
|
30
|
+
"forge_import",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const BRIDGE_DESCRIPTIONS = {
|
|
34
|
+
forge_selected: "List the instances currently selected in Studio (full path + class).",
|
|
35
|
+
forge_tree: "Show an indented tree of instances in Studio (default root: workspace). Use to discover names before reading or editing.",
|
|
36
|
+
forge_read: "Read a script's source in Studio by dotted path (e.g. 'ServerScriptService.Game.Main'), or dump an instance's properties.",
|
|
37
|
+
forge_write: "Write COMPLETE source to a script in Studio by path. Use after forge_read; send the full new source, never partial edits.",
|
|
38
|
+
forge_create: "Create an instance in Studio under a parent path, optionally setting simple properties.",
|
|
39
|
+
forge_delete: "Delete an instance in Studio by path. Destructive.",
|
|
40
|
+
forge_run: "Run a Luau snippet inside Studio and report return values or the error. For diagnostics only.",
|
|
41
|
+
forge_screenshot: "Save a screenshot of the Studio 3D viewport to the user's computer (file only; the model cannot see it).",
|
|
42
|
+
forge_game_info: "Place id, job id, Studio mode, selection count.",
|
|
43
|
+
forge_viewport: "Capture the Studio 3D viewport as a PNG image that the model can actually SEE (vision). Use to visually inspect the scene after making changes.",
|
|
44
|
+
forge_get_property: "Read a single property of an instance in Studio by dotted path.",
|
|
45
|
+
forge_set_property: "Set a single property of an instance in Studio. Destructive.",
|
|
46
|
+
forge_get_attributes: "List all attributes (name = value) on an instance in Studio.",
|
|
47
|
+
forge_set_attribute: "Set a string/number/boolean attribute on an instance in Studio. Destructive.",
|
|
48
|
+
forge_select: "Set the Studio selection to the given instance paths.",
|
|
49
|
+
forge_checkpoint: "Mark the current Studio state as a named checkpoint in the undo history. Call it BEFORE a batch of destructive changes so they can be rolled back as a unit with forge_undo.",
|
|
50
|
+
forge_undo: "Undo Studio changes: pass to='name' to roll back to a named checkpoint, or omit to undo one step. Destructive.",
|
|
51
|
+
forge_checkpoints: "List recorded checkpoints (name, index, steps back) and the current change-history index.",
|
|
52
|
+
forge_find: "Find instances in Studio by name substring (pattern) and/or ClassName (class_name). Returns up to `limit` full paths (default 50, max 200).",
|
|
53
|
+
forge_bulk_create: "Create many instances in one call (paste-style): items[] of {path: parent path, class_name, name?, properties?}. Destructive.",
|
|
54
|
+
forge_snapshot: "Capture the current instance tree under a name, so forge_diff can show what changed later. Keeps the last 10.",
|
|
55
|
+
forge_diff: "Compare a snapshot to the current instance tree: added (+) and removed (-) instances. Omit name to diff the most recent snapshot.",
|
|
56
|
+
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6).",
|
|
57
|
+
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes.",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const BRIDGE_SCHEMAS = {
|
|
61
|
+
forge_selected: { type: "object", properties: {}, additionalProperties: false },
|
|
62
|
+
forge_tree: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
root: { type: "string", description: "Root service (workspace, ServerScriptService, …). Default workspace." },
|
|
66
|
+
max_depth: { type: "integer", description: "1-6. Default 3." },
|
|
67
|
+
},
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
forge_read: { type: "object", properties: { path: { type: "string" } }, required: ["path"], additionalProperties: false },
|
|
71
|
+
forge_write: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: { path: { type: "string" }, source: { type: "string" }, create: { type: "boolean" } },
|
|
74
|
+
required: ["path", "source"],
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
},
|
|
77
|
+
forge_create: {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
parent_path: { type: "string" },
|
|
81
|
+
class_name: { type: "string" },
|
|
82
|
+
name: { type: "string" },
|
|
83
|
+
properties: { type: "object" },
|
|
84
|
+
},
|
|
85
|
+
required: ["parent_path", "class_name"],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
forge_delete: { type: "object", properties: { path: { type: "string" } }, required: ["path"], additionalProperties: false },
|
|
89
|
+
forge_run: { type: "object", properties: { code: { type: "string" } }, required: ["code"], additionalProperties: false },
|
|
90
|
+
forge_screenshot: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: { name: { type: "string" }, width: { type: "integer" }, height: { type: "integer" } },
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
forge_game_info: { type: "object", properties: {}, additionalProperties: false },
|
|
96
|
+
forge_viewport: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
width: { type: "integer", description: "256-1280. Default 1024." },
|
|
100
|
+
height: { type: "integer", description: "240-720. Default 576." },
|
|
101
|
+
},
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
},
|
|
104
|
+
forge_get_property: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: { path: { type: "string" }, property: { type: "string" } },
|
|
107
|
+
required: ["path", "property"],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
forge_set_property: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
path: { type: "string" },
|
|
114
|
+
property: { type: "string" },
|
|
115
|
+
value: { type: "string", description: "New value (numbers/booleans coerced)" },
|
|
116
|
+
},
|
|
117
|
+
required: ["path", "property", "value"],
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
},
|
|
120
|
+
forge_get_attributes: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: { path: { type: "string" } },
|
|
123
|
+
required: ["path"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
forge_set_attribute: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
path: { type: "string" },
|
|
130
|
+
name: { type: "string" },
|
|
131
|
+
value: { type: "string", description: "New value (string/number/boolean)" },
|
|
132
|
+
},
|
|
133
|
+
required: ["path", "name", "value"],
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
},
|
|
136
|
+
forge_select: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {
|
|
139
|
+
paths: { type: "array", items: { type: "string" }, description: "Dotted instance paths to select" },
|
|
140
|
+
},
|
|
141
|
+
required: ["paths"],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
},
|
|
144
|
+
forge_checkpoint: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: { name: { type: "string", description: "Checkpoint label, e.g. 'before-car-tweaks'" } },
|
|
147
|
+
required: ["name"],
|
|
148
|
+
additionalProperties: false,
|
|
149
|
+
},
|
|
150
|
+
forge_undo: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: { to: { type: "string", description: "Checkpoint name to roll back to (omit to undo one step)" } },
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
},
|
|
155
|
+
forge_checkpoints: { type: "object", properties: {}, additionalProperties: false },
|
|
156
|
+
forge_find: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: {
|
|
159
|
+
pattern: { type: "string", description: "Name substring, case-insensitive" },
|
|
160
|
+
class_name: { type: "string", description: "Exact ClassName, e.g. Part, Script" },
|
|
161
|
+
limit: { type: "integer", description: "Max results, 1-200. Default 50." },
|
|
162
|
+
},
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
},
|
|
165
|
+
forge_bulk_create: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
items: {
|
|
169
|
+
type: "array",
|
|
170
|
+
description: "Instances to create (max 200)",
|
|
171
|
+
items: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
path: { type: "string", description: "Parent dotted path" },
|
|
175
|
+
class_name: { type: "string", description: "Instance class, e.g. Part" },
|
|
176
|
+
name: { type: "string", description: "Instance name (default: class_name)" },
|
|
177
|
+
properties: { type: "object", description: "Optional properties to set" },
|
|
178
|
+
},
|
|
179
|
+
required: ["path", "class_name"],
|
|
180
|
+
additionalProperties: false,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
required: ["items"],
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
},
|
|
187
|
+
forge_snapshot: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: { name: { type: "string", description: "Snapshot label (default: auto)" } },
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
},
|
|
192
|
+
forge_diff: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: { name: { type: "string", description: "Snapshot name (default: most recent)" } },
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
},
|
|
197
|
+
forge_export: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
path: { type: "string", description: "Dotted path of subtree root (default: workspace)" },
|
|
201
|
+
depth: { type: "integer", description: "Tree depth 1-6 (default 3)" },
|
|
202
|
+
},
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
},
|
|
205
|
+
forge_import: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: {
|
|
208
|
+
json: { type: "string", description: "The export JSON text" },
|
|
209
|
+
path: { type: "string", description: "Plugin-folder file containing the export JSON" },
|
|
210
|
+
parent: { type: "string", description: "Parent path to import under (default: workspace)" },
|
|
211
|
+
name: { type: "string", description: "Rename the root instance (optional)" },
|
|
212
|
+
dry_run: { type: "boolean", description: "Only report what would be created" },
|
|
213
|
+
},
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Tools that modify the DataModel — gated by the approval prompt.
|
|
219
|
+
const DESTRUCTIVE = new Set([
|
|
220
|
+
"forge_write",
|
|
221
|
+
"forge_create",
|
|
222
|
+
"forge_delete",
|
|
223
|
+
"forge_run",
|
|
224
|
+
"forge_set_property",
|
|
225
|
+
"forge_set_attribute",
|
|
226
|
+
"forge_undo",
|
|
227
|
+
"forge_bulk_create",
|
|
228
|
+
"forge_import",
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
export function bridgeTools(bridgeServer) {
|
|
232
|
+
return BRIDGE_TOOL_NAMES.map((name) => ({
|
|
233
|
+
name,
|
|
234
|
+
description: BRIDGE_DESCRIPTIONS[name],
|
|
235
|
+
inputSchema: BRIDGE_SCHEMAS[name],
|
|
236
|
+
tier: "bridge",
|
|
237
|
+
requiresApproval: DESTRUCTIVE.has(name),
|
|
238
|
+
// Returns a plain string, or {text, image:{base64, mediaType}} when the
|
|
239
|
+
// tool produced an image (forge_viewport).
|
|
240
|
+
execute: async (args) => {
|
|
241
|
+
const out = await bridgeServer.submit(name, args, { timeoutMs: 60000 });
|
|
242
|
+
if (!out.ok) return `ERROR: ${out.error}`;
|
|
243
|
+
const r = out.result;
|
|
244
|
+
if (r && typeof r === "object") {
|
|
245
|
+
const image =
|
|
246
|
+
typeof r.imageBase64 === "string" && r.imageBase64.length
|
|
247
|
+
? { base64: r.imageBase64, mediaType: r.mediaType || "image/png" }
|
|
248
|
+
: undefined;
|
|
249
|
+
return { text: String(r.text ?? ""), image };
|
|
250
|
+
}
|
|
251
|
+
return String(r ?? "");
|
|
252
|
+
},
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Wrap Studio's built-in MCP tools as roforge tools. Names are prefixed to
|
|
257
|
+
// avoid clashes with bridge tools; descriptions pass through.
|
|
258
|
+
|
|
259
|
+
// Studio MCP tools that return a scene capture (vision) — matched by name or
|
|
260
|
+
// description so the model is told they produce a SEEABLE image.
|
|
261
|
+
const CAPTURE_NAME_RE = /screenshot|capture|render|image|viewport/i;
|
|
262
|
+
const CAPTURE_DESC_RE = /screenshot|capture|render|image of|viewport/i;
|
|
263
|
+
export function looksLikeCapture(t) {
|
|
264
|
+
return CAPTURE_NAME_RE.test(t.name || "") || CAPTURE_DESC_RE.test(t.description || "");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function mcpCaptureNames(rawTools) {
|
|
268
|
+
return (rawTools || []).filter(looksLikeCapture).map((t) => t.name);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function mcpTools(mcpClient) {
|
|
272
|
+
return (mcpClient._listedTools || []).map((t) => ({
|
|
273
|
+
name: `studio_${t.name}`,
|
|
274
|
+
description:
|
|
275
|
+
(t.description || `Studio MCP tool: ${t.name}`) +
|
|
276
|
+
(looksLikeCapture(t) ? " Returns an image the model can actually SEE (vision)." : ""),
|
|
277
|
+
inputSchema: t.inputSchema || { type: "object", properties: {} },
|
|
278
|
+
tier: "mcp",
|
|
279
|
+
_mcpName: t.name,
|
|
280
|
+
_isCapture: looksLikeCapture(t),
|
|
281
|
+
// MCP write tools are usually named create_*/set_*/delete_* — approve those.
|
|
282
|
+
requiresApproval: /^(create|set|delete|remove|rename|move|execute|run|write|update|add|destroy|insert|sync|push)/i.test(t.name),
|
|
283
|
+
execute: async (args) => {
|
|
284
|
+
const out = await mcpClient.callTool(t.name, args);
|
|
285
|
+
if (out.isError && !out.image) return `ERROR: ${out.text}`;
|
|
286
|
+
// structured so a returned image reaches the model's vision channel
|
|
287
|
+
return { text: out.text, image: out.image };
|
|
288
|
+
},
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// After a successful tools/list, store the raw list and build wrapped tools.
|
|
293
|
+
export function mcpToolsFromList(mcpClient, rawTools) {
|
|
294
|
+
mcpClient._listedTools = rawTools;
|
|
295
|
+
return mcpTools(mcpClient);
|
|
296
|
+
}
|