npm.io
3.8.3 • Published 6h agoCLI

roblox-mcp-server

Licence
MIT
Version
3.8.3
Deps
3
Size
538 kB
Vulns
0
Weekly
628
Install scriptsThis package runs scripts during installation (preinstall/install/postinstall)

Roblox Executor MCP

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.

install whats new clients npm ci


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 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-memory walks getgc(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-remotes indexes every remote/bindable and returns matches with how many scripts reference each.
  • scanX-callers returns the scripts that mention a remote name.
  • scanX-scripts searches decompiled sources. this one is heavy (decompiles the whole client on a cold index) so prefer the server side script-grep which 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 1s

run -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 prompt

built 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 status

new 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

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:

  • filter plain substring (case-sensitive)
  • pattern lua string pattern (prefix/suffix/char-class matching)
  • exclude substring to drop (applied after filter/pattern)
  • level only Output / Info / Warning / Error
  • since epoch timestamp, returns only newer lines so you can poll incrementally instead of rereading the whole window
  • dedupConsecutive collapses repeated lines into one repeated=N entry, kills tick-spam logs
  • format json (default, JSON-lines with relative +0.234s timestamps for cheap token cost) or raw (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 aux that breaks chunk concatenation, and UpvalueScanner fetches it at runtime anyway)
  • import("rbxassetid://...") falls through to game:GetObjects so 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-hydroxide loads the gitlab CI release bundle, pins getgenv().oh
  • hydroxide-scan-upvalues greps every non-executor closure's upvalues for a substring (find AC config tables, walkspeed limits)
  • hydroxide-scan-constants same shape but bytecode constants (find hardcoded kick strings, remote names)
  • hydroxide-list-modules filters getloadedmodules() cheaper than walking DataModel
  • hydroxide-spy-closure hook + 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, no n = 1 array 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-clients JSON dropped from pretty to compact (4-5x smaller payload)
  • default limits trimmed:
    • script-grep limit 50 → 20, maxMatchesPerScript 20 → 5, contextLines 2 → 1
    • dump-visible-ui maxItems 500 → 150
    • get-console-output, search-instances, get-remote-spy-logs limit 50 → 25
    • get-descendants-tree maxChildren 50 → 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/version compare
  • 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 directly

from source:

git clone https://gitlab.com/DexCodeSX/roblox-executor-mcp
cd roblox-executor-mcp
npm install
npm run build
npm start

server 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:16384

paste 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:harnesses

writes 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


not affiliated with Roblox Corporation. for research and learning. use responsibly.

Keywords