roblox-mcp-server
Roblox Executor MCP v3.8.3
DexCodeSX fork. heavily rebuilt decompile pipeline, in game UI, version aware connector, push events, hot reload, device aware throttling.
pick your MCP client
| client | guide |
|---|---|
| Claude Desktop + Claude Code | CLAUDE_MCP.md |
| Cursor | CURSOR_MCP.md |
| Windsurf | WINDSURF_MCP.md |
| VS Code (Copilot Chat) | VSCODE_MCP.md |
| Zed | ZED_MCP.md |
| Cline | CLINE_MCP.md |
| Roo Code | ROOCODE_MCP.md |
| Continue | CONTINUE_MCP.md |
each file has the exact config path, JSON shape, and copy paste snippet for that client. all of them boot the server with npx -y roblox-mcp-server, no clone or build needed.
new here? tutorial.md walks the whole thing end to end, install node, wire up your client, load the connector, per platform (termux/android, macos, windows, linux) plus the cross-device setup.
running the AI on a different machine than Roblox (e.g. Delta on Android, AI on a laptop)? see docs/advanced.md for the --baseurl relay and connector options.
what it does
bridges any MCP capable client (Claude Code, Cursor, Windsurf, etc) to a Roblox executor running in a real game session. lets the model decompile scripts, spy remotes, click buttons, type text, fire signals, run lua, all from chat.
upstream by notpoiu. this fork rebuilds the decompile path end to end and adds a real UI.
new in v3.8.3
fix spy-closure clobbering the transport id, hardened so it cant happen again
hydroxide-spy-closure always timed out. traced it live: the command went out with no correlation id, so when the client posted the result back the server saw data.id == nil, dropped it (if (!data.id) return), and the tool sat 15s. cause was a name collision, the tool sends { ...data, id, type } and the dispatcher builds the message as { id: requestId, ...data, type }, so the tool's own optional id param (the spy id, undefined for list/start) spread over the generated request id and wiped it. spy-closure was the only tool with a param literally named id. fixed two ways: the tool now sends the spy id as spyId (connector reads data.spyId), and the dispatcher spreads data first then sets id/type last, so no tool payload can ever clobber the correlation id or type again. connector bumped.
new in v3.8.2
fix http poll double-encoding that killed every returning tool
3.8.0 started batching commands as a json array on the http poll. but the poll queue holds already-encoded json strings, and the route json-stringified the whole array again, so the body went out as ["{...}"], an array containing a string instead of an array of objects. the connector decoded it, hit command.type on a string, got nil, and skipped every command. so over http transport: the command got delivered, never ran, never posted a result, and the tool timed out at 15s. fire-and-forget execute looked fine (it never waits), which masked it. fixed by joining the pre-encoded strings into the array directly instead of re-stringifying. server-side only, reload the connector to get the matching version.
new in v3.8.1
tool descriptions rewritten so the model actually uses them
the power tools (scanX, hydroxide, hot-reload, the remote spy) were described in exploit-dev shorthand, "adds getgenv().scanX", "scan getgc(true) tables", which tells the model the mechanism but never when to reach for it. since the model always has get-data-by-code to write raw lua itself, it just did that and never touched the specialized tools. rewrote every one of those descriptions trigger-first: lead with the situation ("when you need to find a config table in memory...") and say why it beats writing the lua yourself (pre-built, capped, clean rows). added disambiguators where two tools overlapped (memory table vs closure upvalue) and pushed subscribe-remote to be the answer for "which remote does this action fire" instead of a hand-written __namecall hook. descriptions only, no behavior change.
new in v3.8.0
http poll path stops dropping commands
clients on the http fallback transport (no websocket) used to have a single command mailbox on the server, so if two tool calls landed inside the same 100ms poll window the first one got clobbered and silently lost. now the server queues commands per client and the poll long-holds: it parks the request and hands back the whole batch the instant a command arrives (or after 8s idle), and the connector runs the batch and only sleeps when there was nothing. fewer round trips, no dropped commands. the hold is kept under the 10s liveness window so a parked poll can't read as stale and flap the client out. connector bumped, ported the idea from upstream PR #20 without the extra knobs.
new in v3.7.0
loader command
roblox loader prints the executor loadstring for whatever server you're pointed at. it reads the same base url everything else does ($ROBLOX_MCP_URL, --baseurl, or the default), so on a cross-machine setup it bakes the right BridgeURL into the snippet for you instead of making you remember the ip. pipe it, or -c to drop it on the clipboard (pbcopy/clip/wl-copy/xclip/xsel/termux-clipboard-set, whichever's there). --json gives {loader, bridge}.
roblox loader # localhost
roblox loader --baseurl http://192.168.1.20:16384 -c
new in v3.6.0
scanX integration, raw memory search
scanX loads like cobalt and hydroxide now. ensure-scanX pulls it from gitlab headless (no window on the user screen, it sets scanX_headless before load), then four query tools call into getgenv().scanX for data:
scanX-memorywalksgetgc(true)tables and matches on keys/values. this is the one thing no other tool does, raw memory search. good for anti-cheat config tables, cached remote tables, thresholds. cheap, getgc capped at 1500.scanX-remotesindexes every remote/bindable and returns matches with how many scripts reference each.scanX-callersreturns the scripts that mention a remote name.scanX-scriptssearches decompiled sources. this one is heavy (decompiles the whole client on a cold index) so prefer the server sidescript-grepwhich searches already-cached sources. it's here for when grep has no index.
every row comes back as plain serializable data, no Instances or live refs. results capped at 250.
new in v3.5.0
spy -f actually streams now, and use works
the HTTP /api/tool route had a closed whitelist, so roblox spy, spy -f and use were hitting "Unknown tool type" and doing nothing. added ensure-remote-spy, subscribe-remote, get-remote-spy-logs to it and gave set-active-client a server side handler. spy -f now loads the cobalt spy, turns streaming on, and tails the live remote-call SSE feed. spy without -f dumps the recent log. same api the dashboard uses, no new tools on the connector.
new in v3.4.0
run --watch (hot reload) and watch (live eval)
two commands that turn the CLI into a real dev loop, both built on the same execute / get-data-by-code the dashboard already uses. no new server tools.
roblox run fly.lua -w # re-exec the file every time you save it
roblox watch 'return #game.Players:GetPlayers()' -n 1srun -w watches the file and re-runs it on save. it wraps your code so each reload tears down the previous one first: register teardown with STATE.onCleanup(fn) and your RenderStepped loops / Drawing objects get disconnected before the new version runs, so repeated saves don't stack loops and lag the game out. STATE is a table that survives reloads (stash config/handles there). a compile or runtime error just warns and keeps watching, it never kills the connector.
-- fly.lua
local RunService = game:GetService("RunService")
local conn = RunService.RenderStepped:Connect(function() end)
STATE.onCleanup(function() conn:Disconnect() end) -- runs on next save
STATE.count = (STATE.count or 0) + 1
print("reloaded", STATE.count)
watch re-runs an eval on an interval and reprints (clears the screen in a tty). -n takes 500ms / 2s / 1m, floored at 250ms so a typo can't hammer the game. both skip a tick if the previous call is still in flight, so slow responses never pile up. --json makes watch emit one {t,result,error} line per tick.
a CLI for when you don't want the dashboard
roblox ships as a second bin. zero dependencies, talks to the same :16384 HTTP api the dashboard does, so it works anywhere node runs (phone over ssh, mac, linux, a CI box) without a browser.
roblox status # role, uptime, connected clients
roblox clients # list game clients
roblox use <clientId> # pick the active one
roblox eval 'return #game.Players:GetPlayers()'
roblox exec -F fly.lua # load + run a local script
roblox run fly.lua -w # hot reload on save
roblox watch 'return tick()' -n 1s # live eval loop
roblox grep FireServer # search decompiled scripts
roblox logs -f # tail console output
roblox spy -f # tail remote spy
roblox tree game.Workspace
roblox info
roblox repl # interactive luau promptbuilt for both humans and agents. --json flips every command to machine output (streams emit one object per line, pipe straight into jq), data goes to stdout and status/errors to stderr so pipes stay clean, colors auto-off when not a tty or NO_COLOR is set, and exit codes mean something (0 ok, 1 error, 2 server/client not reachable). point it elsewhere with --baseurl or $ROBLOX_MCP_URL.
roblox logs -f --json | jq -r .msg
ROBLOX_MCP_URL=http://192.168.1.20:16384 roblox statusnew in v3.3.0
a CLI for when you don't want the dashboard
the first cut of the roblox bin: status, clients, use, exec, eval, grep, logs -f, spy -f, tree, info, repl. zero deps, same :16384 api as the dashboard, --json everywhere, tty-aware colors, exit codes 0/1/2.
new in v3.2.0
dashboard repo link points at this fork now, with the right logo
the footer/topbar link still went to upstream github.com/notpoiu/roblox-mcp with a GitHub octocat. this is a GitLab fork, so both the URL and the glyph were wrong. swapped to gitlab.com/DexCodeSX/roblox-executor-mcp and the GitLab tanuki logo in both spots (desktop sidebar footer + mobile topbar).
new in v3.1.9
mobile: client selector + uptime/github were hidden by the 3.1.8 rebuild
the bottom-tab rebuild hid .topbar-left (that's the client selector, the only way to switch clients on a phone) and the sidebar footer (uptime + github). put both back:
- client selector stays in the topbar on mobile, topbar grid is now
auto 1fr auto(selector / section / right). - uptime + github surface in the topbar-right on mobile (they live in the sidebar footer on desktop, which is hidden when the sidebar is a tab bar). JS mirrors uptime to both chips.
- ≤380px drops just the section label to make room, keeps the back button.
new in v3.1.8
dashboard is actually usable on a phone now
the old "mobile support" was a display:none on the sidebar (so you couldn't navigate) plus a pile of media rules targeting class names that don't exist in the markup. dead code. ripped it out and rebuilt against the real layout:
- left sidebar becomes a fixed bottom tab bar on phones (thumb-reachable, the native pattern), instead of vanishing. notch-safe via
env(safe-area-inset-bottom). - tools view stacks: the tool picker turns into a horizontal scroll strip above the runner, runner goes full width.
- params table stacks label-over-input, Send button goes full width.
- scripts file rows drop the Size column on narrow screens (name + lines + menu stay), code viewer shrinks.
- logs table column widths fixed for narrow viewports.
- settings/modal footers stack, modal becomes a bottom sheet.
(hover: none)block kills sticky hover transforms and floors tap targets at 44px.
PC layout is byte-for-byte unchanged, all of this lives below the breakpoints.
new in v3.1.7
get-console-output got real filtering
upstream shipped a plain substring filter. this goes further. one call now takes:
filterplain substring (case-sensitive)patternlua string pattern (prefix/suffix/char-class matching)excludesubstring to drop (applied after filter/pattern)levelonlyOutput/Info/Warning/Errorsinceepoch timestamp, returns only newer lines so you can poll incrementally instead of rereading the whole windowdedupConsecutivecollapses repeated lines into onerepeated=Nentry, kills tick-spam logsformatjson(default, JSON-lines with relative+0.234stimestamps for cheap token cost) orraw(structured object)
dashboard exposes substring / pattern / exclude / level / collapse fields.
new in v3.1.6
hydroxide-spy-closure hot global blocklist
hookfunction(print, ...) and friends recurse through the spy log builder (tostring calls print, print calls hook, hook calls tostring) and deadlock the connector inside task.wait(15000). now refused at the source with a {status="blocked"} response. blocked targets: print, warn, error, pcall, xpcall, tostring, tonumber, type, typeof, require, task.wait, task.spawn, task.defer.
spy log builder also added a not checkcaller() guard so executor-side tostrings don't log themselves.
bundler fixes for the hydroxide release
- ohaux.lua dropped from the inlined section (it has a top-level
return auxthat breaks chunk concatenation, and UpvalueScanner fetches it at runtime anyway) import("rbxassetid://...")falls through togame:GetObjectsso the UI asset resolves
both shipped as v1.0.1 and v1.0.2 on the MC-Hydroxide release branch.
new in v3.1.5
hydroxide MCP tools
Hydroxide is now reachable from chat. 5 new MCP tools:
ensure-hydroxideloads the gitlab CI release bundle, pinsgetgenv().ohhydroxide-scan-upvaluesgreps every non-executor closure's upvalues for a substring (find AC config tables, walkspeed limits)hydroxide-scan-constantssame shape but bytecode constants (find hardcoded kick strings, remote names)hydroxide-list-modulesfiltersgetloadedmodules()cheaper than walking DataModelhydroxide-spy-closurehook + log calls on any function via a Luau getter expression. captures last 50 calls
bundle pulled from DexCodeSX/MC-Hydroxide release branch, single fetch.
new in v3.1.4
one command version bumps
npm run bump 3.1.5 (or patch / minor / major) walks package.json, package-lock.json, ui.luau and README badges in one shot. no more hand editing four files and getting it wrong on the third.
new in v3.1.3
execute + get-data-by-code errors actually surface
loadstring(data.source)() used to swallow both compile and runtime errors. now the connector compiles first, surfaces syntax failures via task.defer(error, ...), wraps runtime in xpcall with traceback, and tags every error with a chunk name ([mcp:mcp-execute], [mcp:mcp-dashboard]) so you can tell mcp scripts apart from game scripts in the dev console.
get-data-by-code returns a {__mcpError, kind, message} envelope on failure. both the MCP tool path and the dashboard route unwrap it into a readable [compile] ... or [runtime] ... line instead of dumping JSON.
dashboard is now mobile aware
added breakpoints for ≤680px and ≤380px. topbar collapses, sidebar swaps to bottom padding, full-sheet modals on phones, 40px min tap targets on touch-only devices, dropdown stops overflowing 360px viewports. tested on Termux + Chrome at 412x915.
smaller npm tarball
dropped 80+ .d.ts files nobody imports (this is a bin, not a lib), minified dashboard css/js/html with esbuild, guarded the prepare script so end users don't rebuild on install. 183 files → 97, dashboard assets 152 KB → 107 KB.
new in v3.1.2
token-safe MCP responses
every tool call used to dump pretty-printed Lua with a { "value", n = 1 } envelope. on a 200k context that adds up fast. v3.1.2:
- connector serializes compact, no
Prettify, non = 1array tail for single-value returns - server-side
tokensafe()post-filter strips legacy envelopes from older connectors and hard-caps each response at 12000 chars with a clean truncation footer list-clientsJSON dropped from pretty to compact (4-5x smaller payload)- default limits trimmed:
script-greplimit50 → 20,maxMatchesPerScript20 → 5,contextLines2 → 1dump-visible-uimaxItems500 → 150get-console-output,search-instances,get-remote-spy-logslimit 50 → 25get-descendants-treemaxChildren50 → 20
bump the per-tool limit explicitly when you actually need the breadth, otherwise the new defaults already cover 90% of asks for a fraction of the tokens.
device aware throttling (anti crash/lag)
connector now detects mobile / console / desktop on boot and tunes everything heavy.
| profile | upload batch | scan chunk | scan sleep | spy interval |
|---|---|---|---|---|
| mobile | 3 | 80 | 60ms | 600ms |
| console | 4 | 120 | 40ms | 400ms |
| desktop | 8 | 250 | 10ms | 200ms |
decomp mapping, gc scans, spy polling all read from DeviceProfile. mobile and Xbox stop choking under the desktop tuned defaults.
push events instead of polling
connector server bridge now carries outbound event messages. server fans them out over an SSE stream at /api/events (filter with ?kind= and ?clientId=).
new MCP tool subscribe-remote flips a diff poller inside the connector. every new Cobalt remote call gets pushed as a remote-call event. agents stop hammering get-remote-spy-logs in a loop.
hot reload
new MCP tool hot-reload ships a Luau chunk to the active client. state survives across reloads via getgenv().STATE. takes either inline source or a server local filePath.
-- inside your live script
getgenv().STATE.counter = (getgenv().STATE.counter or 0) + 1
print("reloaded", getgenv().STATE.counter)
new in v3.0.0
decompile pipeline that doesn't fail
old chain (lua.expert then medal then konstant) ran client side, fell over on big scripts. PlayerModule decompile would 50x after 112 seconds.
new pipeline lives on the server:
client sends bytecode
↓
[1] SHA384 cache hit instant return (every decompile cached forever)
↓
[2] CoreScript path? pull pristine source from MaximumADHD/Roblox-Client-Tracker
↓
[3] race lua.expert + medal + konstant in parallel first valid wins
↓
[4] fall back to native executor decompile
PlayerModule and all CoreScripts now return clean Roblox source in under a second.
new HTTP api
| route | what it does |
|---|---|
POST /api/decompile |
the orchestrator above. takes bytecode, returns source |
POST /api/decompile/disasm |
full bytecode disasm with LOP_NATIVECALL highlighting |
POST /api/decompile/constants |
extract every string / number / import without decompiling |
POST /api/decompile/diff |
per proto hash diff between two bytecode versions |
GET /api/decompile/stats |
cache hit rate, provider wins, entry count |
GET /api/version |
server + connector version, platform aware update hints |
GET /script.luau |
served connector (always in sync with running server) |
GET /ui.luau |
in game settings + connection UI |
in game UI
new ui.luau ships a glassy connection panel. drop it in the executor:
loadstring(game:HttpGet("https://gitlab.com/DexCodeSX/roblox-executor-mcp/-/raw/main/ui.luau"))()
features:
- bridge URL input with paste / clear / autosave
- version pill that turns green / yellow / red based on
/api/versioncompare - platform aware "how to update" hint (macos / linux / termux / wsl / windows)
- live activity log of the handshake
- settings drawer with three toggles (websocket / lazy decomp / autoconnect)
- drag works on mouse + touch (PC + mobile)
- animated background orbs that drift behind the window
- saved URL auto checks on open
version sync
connector reports its version + platform to /api/version. server replies with one of:
| status | what happens |
|---|---|
current |
green pill, boots normally |
outdated |
yellow banner with platform specific update command, boots anyway |
blocked |
red banner, boot refused, you must update first |
MIN_BOOTABLE_CONNECTOR in src/http/routes/api/version.ts gates blocked vs outdated. bump it when the server adds a contract the old connector can't speak.
install
via npm (recommended):
npm install -g roblox-mcp-server
# postinstall prints the absolute boot path, something like:
# /usr/lib/node_modules/roblox-mcp-server/dist/index.js
# point your MCP client at that path, or use the `roblox-mcp` bin directlyfrom source:
git clone https://gitlab.com/DexCodeSX/roblox-executor-mcp
cd roblox-executor-mcp
npm install
npm run build
npm startserver listens on :16384. dashboard at http://localhost:16384/.
mobile note
if you're running the server on the same Android phone (Termux) and connecting from Roblox on the same phone, localhost:16384 will NOT work. Roblox runs in its own network sandbox and can't see Termux. you MUST tunnel:
cloudflared tunnel --url http://localhost:16384paste the https://*.trycloudflare.com URL into the in game UI. on Windows / macOS / Linux desktop, localhost works fine because the Roblox process and the server share the same OS network namespace.
the UI will refuse to connect to localhost from a mobile device and tell you to start a tunnel.
register with your MCP client
pick one from the clients table above. each file has the exact JSON / YAML for that client.
quick auto installer (Claude Code, Cursor, others where the CLI supports it):
npm run install:harnesseswrites the right config and prints the loader.
usage in roblox
simplest path, recommended:
loadstring(game:HttpGet("https://gitlab.com/DexCodeSX/roblox-executor-mcp/-/raw/main/ui.luau"))()
skip the UI, boot the connector directly:
getgenv().BridgeURL = "your-bridge-url"
loadstring(game:HttpGet("https://gitlab.com/DexCodeSX/roblox-executor-mcp/-/raw/main/connector.luau"))()
options:
getgenv().BridgeURL = "localhost:16384"
getgenv().DisableWebSocket = true -- http polling, steadier on mobile
getgenv().DisableInitialScriptDecompMapping = true -- lazy: start mapping on first /grep call
architecture
src/
decompile/
cache.ts SHA384 disk cache at ~/.roblox-mcp/decompile-cache/
github-mirror.ts maps CoreScript paths to MaximumADHD/Roblox-Client-Tracker
providers.ts lua.expert + medal + konstant racers
orchestrator.ts ties it all together
luau-bytecode.ts pure TS Luau bytecode reader (v3 through v6)
http/
routes/
api/decompile.ts POST orchestrator
api/decompile/disasm.ts POST bytecode disasm + LOP_NATIVECALL scan
api/decompile/constants.ts POST string/number/import extract
api/decompile/diff.ts POST per proto hash diff
api/decompile/stats.ts GET cache stats
api/version.ts GET version + platform compare
script.luau.ts GET connector
ui.luau.ts GET in game UI
tools/ MCP tools (script-grep, get-script-content, etc)
bridge/ connector handshake, websocket fallback
cache survives restarts. entries are content addressed by bytecode SHA384, so the same decompile is never paid for twice across any client, any place, any session.
bytecode disassembler example
curl -X POST http://localhost:16384/api/decompile/disasm \
-H 'Content-Type: application/json' \
-d '{"bytecodeBase64":"BAUDfgEHQF..."}'returns:
-- Bytecode v6, 142 strings, 17 protos
-- proto[0] init (params=0 ups=0 vararg=true stack=12 children=3)
0: GETIMPORT A=0 B=1 C=0 ; game.GetService
1: NAMECALL A=0 B=0 C=0 AUX=... ; "GetService"
plus a nativeCalls array pointing at every LOP_NATIVECALL site. those skip the Lua VM and your hookmetamethod hooks won't see them mid execution. useful for AC analysis.
credits
- upstream: notpoiu/roblox-executor-mcp
- CoreScript mirror: MaximumADHD/Roblox-Client-Tracker
- decompile providers: lua.expert, medal.upio.dev, Konstant
- Luau bytecode spec: luau-lang/luau
not affiliated with Roblox Corporation. for research and learning. use responsibly.