Driving Roblox Studio through MCP
How an AI coding agent reads, writes and tests a live Studio place without breaking it. Every rule here comes from a bug that cost at least one session.
7 min read
How an AI coding agent reads, writes and tests a live Studio place without breaking it. Every rule here comes from a bug that cost at least one session.
- Pick one MCP server
- Guard every write with a landmark
- Pushing code: never through set_script_source
- Pulling code: byte-exact
- The require cache lies
- Edit mode vs playtest
- Stalled bridges and stray processes
- Screenshots and the camera
- Checklist
Pick one MCP server
Two Studio MCP servers can be connected at once (the community robloxstudio server and the built-in
Roblox_Studio one). Their tool lists overlap: script read and write, Luau execution, screenshots,
play and stop. Pick one and say so in the project's agent instructions.
- Default to the community server (
@chrrxs/robloxstudio-mcp). It hasimport_rbxm/export_rbxm(disk-to-Studio and Studio-to-disk), separateeval_server_runtime/eval_client_runtimeduring a playtest,get_runtime_logs,multiplayer_playtest, and retry-safeoperation_ids. - The one exception is uploads. The community server's
upload_assetneeds an Open Cloud key or a cookie in its environment. The built-in server'supload_imageuploads through the logged-in Studio session. It only takes http(s) URLs (serve the folder withpython3 -m http.server 8791 --bind 127.0.0.1) and needs its ownstudio_idfromlist_roblox_studios. It takes a batch and returns a path-to-id map. - Why one server: an agent that silently mixes servers ends up targeting two different Studio windows (see the next section) and uses tooling the developer chose not to.
Guard every write with a landmark
With two Studio windows open, the MCP's "active instance" can flip silently between them, including mid-session, after a reconnect, and reliably after a play/stop. Worse, different tools can route to different windows at the same moment.
Pass
instance_idexplicitly on every call when the server supports it (get_connected_instances). Ids change every time Studio reopens a place, so look them up per session.Make the first line of every mutating
execute_luauan assertion on something only the target place has:assert(game.Name == "MyGame" and workspace:FindFirstChild("MyGameMap"), "wrong place")Bulk tools with no guard (multi-edit, set_properties) should be bracketed by a guarded
execute_luaubefore and after.Why: there is no way to copy scripts between Studio windows over MCP. One wrong-window build means re-emitting every file, and a cleanup script in the wrong window deletes someone else's work.
Pushing code: never through set_script_source
set_script_source and edit_script_lines expand \n inside string literals into real newlines in
transit. A line like print("a\nb") arrives split in two and the script fails with
Malformed string; did you forget to finish it?, pointing at a line that looks fine. The push itself
reports success; the error only shows in the runtime log.
Push through a Roblox XML model instead:
- Write the
.luaufile to disk. - Wrap it in
.rbxmx(seetools/stage.py, or the minimal shape below). import_rbxmwith the file path. The source goes disk to Studio without passing through the JSON transport, and without spending context tokens a second time.
<roblox version="4">
<Item class="ModuleScript" referent="RBX0">
<Properties>
<string name="Name">Foo</string>
<ProtectedString name="Source"><![CDATA[ ...luau... ]]></ProtectedString>
</Properties>
</Item>
</roblox>
- Escape
]]>inside the source as]]]]><![CDATA[>. - For
class="Script"add<token name="RunContext">1</token>(0 Legacy, 1 Server, 2 Client). - Import into
ServerStorageas a staging folder, then copy.Sourceonto the live scripts in one guardedexecute_luau(see multi-agent.md). Importing straight on top of a live folder replaces things other agents own. - Tool calls in one response are not ordered. "Destroy old folder" and "import new folder" sent together can land import-first and then get destroyed. Sequence them across turns, or import into a staging name and do the swap in one script.
Pulling code: byte-exact
When a collaborator edits live in Studio, Studio is the source of truth. Pull before you edit.
execute_luaureturning.Sourceworks for small scripts, but big results get truncated or saved to a side file, and the JSON round trip can alter escapes.- Byte-exact route:
export_rbxmthe folder to a binary.rbxmon disk, then extract every script withtools/rbxm_extract.py. This is also how you take a full snapshot for reviewers. - Use
tools/fingerprint.luauplustools/snapshot_diff.pyto check which scripts changed since your last pull without downloading any source.
The require cache lies
execute_luauruns in its own Luau context.require(module)there returns a fresh copy, not the running game's. Reading internal state from it reports the probe's own copy. To observe runtime state, have the module publish it (SetAttributeon an instance) or check side effects.- That cache persists across
execute_luaucalls. Require a config, push a new source, require it again: you still get the old table. A verification pass can "confirm" stale numbers.- To check an edited module, read
.Sourceback, orloadstring(mod.Source)()(needs LoadStringEnabled in edit context, which the plugin has), or clone the module and require the clone. - A module that requires an edited config gets the cached config too, so clone the config as well.
- To check an edited module, read
- Only trust
requirefor modules you have not edited this session.
Edit mode vs playtest
- Instance changes made during a playtest are discarded on Stop.
execute_luautargets the edit DataModel even mid-playtest, soInstance.new,:Destroy()and property writes look applied and then vanish. Script source edits throughScriptEditorService:UpdateSourceAsyncsurvive. Stop first; verify again with nothing running. - Edit-mode verification works when play mode is wedged. Import the modules,
requirethe services and drive them with a mock player table (UserId,GetAttribute,SetAttribute,FindFirstChild,Character). This also keeps load off a weak machine. workspace.StreamingEnabledcan only be set in edit mode (a running script gets "lacking capability Plugin"). Set it withset_properties; in game code onlywarnif it is off.execute_luaucalls time out at about 30 s. Build in batches and never loop-wait inside one call; wait in a background shell instead.
Stalled bridges and stray processes
Symptoms seen repeatedly:
multiplayer_playtest startorsolo_playtest starttimes out; peers stay "queued; not_executed".- Every later start says "a test is already running" while no peers are registered.
- A child Studio process (
-editpid,--task EditFile) stays alive for 10+ minutes. capture_screenshotreports "window appears minimized or not rendering".
What to do:
- Stop, wait, retry once. Then report it rather than looping. Each retry loads the machine.
- The real fix is a Studio restart, which loses unsaved work, so ask the developer to save and restart. Team Create keeps work across a crash, a local file does not.
- Meanwhile verify in edit mode (above).
- Never kill Studio processes blindly. A failed
launchleft twoRobloxStudio --taskstrays that ignored SIGTERM and blinded the real Studio; but killing the wrong pid kills the developer's session. List withps aux | grep RobloxStudio, show the developer what you would kill, and onlykill -9strays you started. Never use the server'slaunchaction to get a fresh place: ask the developer to do File > New.
Unfocused Studio is capped at 15 fps (exactly). Frame-rate A/B tests in a background window are meaningless; ask for the window to be focused, or compare script time with the profiler instead.
Screenshots and the camera
- Studio has one viewport camera and
capture_screenshotcaptures it. Several agents moving it capture each other's views; follow the camera lock in multi-agent.md. - Setting
cam.CFramealone can keep the position but snap the rotation back. Setcam.Focusto the target in the same call, wait about 0.4 s, then capture. Prove the shot is yours with a centreViewportPointToRayplusworkspace:Raycastand print what it hit. - Captures are 2x retina. Their pixels are not click coordinates: halve them, or read a button's
AbsolutePosition + AbsoluteSize / 2and add the GUI inset (58 px) forsimulate_mouse_input. - Captures never draw BillboardGuis. ScreenGuis draw during a playtest in current builds; older builds
returned solid magenta. Verify GUIs by a property sweep (
TextFits, overlap of siblingAbsolutePosition/AbsoluteSize,IsLoadedper asset) when the capture is useless. - If the developer is playing inside your playtest (real right-mouse or WASD
InputBeganevents show up), stop driving the client.
Checklist
- One MCP server named in the agent instructions; the other used only for uploads.
-
instance_idon every call; a landmarkassertas line 1 of every mutatingexecute_luau. - Code goes in through
.rbxmx+import_rbxm, neverset_script_source. - Pull Studio source byte-exact before editing anything a collaborator may have touched.
- Never trust
requirefor a module edited this session. - Stop the playtest before making instance changes.
- On a stalled bridge: retry once, then ask for a Studio restart. Never kill unknown processes.