Skip to main content
Glama
README.md
# roblox-studio-mcp

An open-source **[Model Context Protocol](https://modelcontextprotocol.io) (MCP) server** that lets AI assistants — Claude Desktop, Claude Code, or any MCP client — drive **Roblox Studio**: run Luau, build and edit the DataModel, read the scene tree, and manage scripts.

> Status: v0.5 — 25 tools, verified in real Studio. Live server-VM eval during
> playtests, multi-window routing, line-level script editing, tags/attributes,
> official docs + class reflection, a hardened bridge (auth + version
> handshake), and a Rojo-based plugin. Still young; contributions welcome!

## How it works

Roblox Studio cannot open a listening socket, so the plugin drives the loop by
**long-polling** a tiny local HTTP bridge that ships inside the MCP server:

```
┌────────────┐   MCP (stdio)   ┌──────────────────┐   HTTP long-poll   ┌──────────────┐
│ Claude /   │ ◀─────────────▶ │ roblox-studio-mcp │ ◀────────────────▶ │ Studio plugin │
│ MCP client │   JSON-RPC      │  (server+bridge)  │   /poll  /result   │   (Luau)      │
└────────────┘                 └──────────────────┘                    └──────────────┘
```

1. Your MCP client calls a tool (e.g. `create_instance`).
2. The server queues a command; the plugin picks it up via `GET /poll`.
3. The plugin runs it in Studio and posts back via `POST /result`.
4. The server returns the result to the client.

Everything stays on `127.0.0.1` — nothing is exposed to the network.

## Tools

| Tool | What it does |
| --- | --- |
| `get_instances` | List connected Studio instances and their peers (`edit`/`server`). |
| `run_luau` | Execute Luau in the edit VM; captures `return` values and `print`/`warn` output. |
| `eval_server_runtime` | Execute Luau in a running playtest's **server VM** (shared require cache). |
| `create_instance` | Create an Instance and parent it under a path. |
| `set_properties` | Set properties on an existing instance. |
| `delete_instance` | Delete an instance (undoable — re-parents to `nil`, not `Destroy`). |
| `get_tree` | Return the DataModel hierarchy as nested JSON. |
| `get_properties` | Read properties from an instance. |
| `get_selection` | Return the instances the user has selected in Studio. |
| `read_script` | Read a script's source with line numbers; supports line ranges + auto-truncation. |
| `edit_script` | Replace a script's whole source via `ScriptEditorService` (editor-safe, undoable). |
| `edit_script_lines` | Replace an inclusive line range with new text (editor-safe, undoable). |
| `insert_script_lines` | Insert text before a given line (append with `lineCount+1`). |
| `delete_script_lines` | Delete an inclusive line range. |
| `grep_scripts` | Search script sources under a path; returns `{ path, line, text }` matches. |
| `add_tag` / `remove_tag` | Add/remove a CollectionService tag on an instance (undoable). |
| `get_tags` | List an instance's CollectionService tags. |
| `get_tagged` | Return every instance carrying a tag (optionally under a root). |
| `set_attribute` / `delete_attribute` | Set/remove an instance attribute (undoable). |
| `get_attributes` | Return all attributes on an instance. |
| `get_roblox_docs` | Fetch official engine reference docs as markdown (server-side; no Studio needed). |
| `get_class_info` | List a class's members (properties/methods/events) from the API dump, inheritance-aware. |
| `batch` | Run several commands as one all-or-nothing transaction (single undo). |

Mutating tools are wrapped in a `ChangeHistoryService` recording, so every AI
action is a single **Ctrl+Z** in Studio.

## Multiple Studio windows

Several open places can connect to the same server at once. Every plugin-backed
tool accepts an optional **`instance_id`** to pick which window it runs in:

- **One window open** → `instance_id` is optional; calls route to it.
- **Several open, no `instance_id`** → the call returns a **structured error**
  listing the connected windows, so the assistant picks one and retries (it
  never guesses).
- **`instance_id` given** → matched against each window's `instanceId` **or** its
  raw `sessionId`.

Call **`get_instances`** to see what's connected. Each **instance** is a place
(`instanceId` is `place:<placeId>`, or `anon:<uuid>` for an unpublished place)
and lists its live **peers** by role: `edit` is the plugin, and `server` appears
during a playtest (see [Runtime eval](#runtime-eval-live-debugging)). A peer that
stops polling is dropped after ~30s.

## Runtime eval (live debugging)

`eval_server_runtime` runs Luau **inside the server VM of a running playtest**,
sharing that VM's `require` cache — so you can read and change *live* runtime
state (a service's in-memory tables, a match in progress), which `run_luau`
(edit VM) cannot see. It returns `{ returned, output }` just like `run_luau`.

How it works: the plugin injects a small peer script into `ServerScriptService`
when you Connect; during a playtest that script runs in the server VM, connects
to the bridge as a `server` peer, and evaluates what `eval_server_runtime` sends.
Check `get_instances` for a `server` peer to confirm a playtest is live.

**Two one-time prerequisites** (both are developer settings the plugin cannot
set for you):

1. **Allow HTTP Requests** — Game Settings ▸ Security. The server VM needs this
   to reach the bridge.
2. **`ServerScriptService.LoadStringEnabled = true`** — select `ServerScriptService`
   in the Explorer and tick `LoadStringEnabled` in the Properties panel. Runtime
   eval uses `loadstring`; this property is not script-settable.

`eval_server_runtime` executes arbitrary Luau in your live game — it is blocked
by [read-only mode](#read-only-mode), and you should only enable it for trusted
clients.

## Install

### 1. Build the server

```bash
git clone https://github.com/EmirCobanOfficial/roblox-studio-mcp.git
cd roblox-studio-mcp
npm install
npm run build
```

### 2. Build & install the Studio plugin

The plugin is a [Rojo](https://rojo.space) project under `plugin/` — its source
lives as separate modules in `plugin/src/` (see [Plugin layout](#plugin-layout)).
Build it into a model file and drop that into your local Plugins folder:

```bash
# Install Rojo once: https://rojo.space/docs/v7/getting-started/installation/
rojo build plugin/default.project.json -o RobloxStudioMCP.rbxm
```

- In Studio: **Plugins** tab ▸ **Plugins Folder** — drop `RobloxStudioMCP.rbxm` there.
- Restart Studio (or right-click the Plugins pane ▸ *Rescan*).

You'll get an **MCP** toolbar with **Connect** and **Read-only** buttons.

> **Working on the plugin?** Edit the modules under `plugin/src/` and rebuild —
> or run `rojo serve` with the Rojo Studio plugin for live sync during
> development. Studio does **not** hot-reload an installed plugin, so after
> rebuilding the `.rbxm` you must **fully restart Studio** (a *Rescan* is not
> always enough).

### 3. Point your MCP client at the server

**Claude Desktop** — add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "roblox-studio": {
      "command": "node",
      "args": ["/absolute/path/to/roblox-studio-mcp/dist/index.js"]
    }
  }
}
```

**Claude Code**:

```bash
claude mcp add roblox-studio -- node /absolute/path/to/roblox-studio-mcp/dist/index.js
```

### 4. Connect

Open a place in Studio, click **MCP ▸ Connect**, then ask your assistant to do
something like *"create a red neon part 10 studs above the baseplate."*

> **HTTP requests must be allowed.** The plugin tries to set
> `HttpService.HttpEnabled = true` for you. If requests are blocked, enable
> **Game Settings ▸ Security ▸ Allow HTTP Requests** and reconnect.

## Encoding Roblox datatypes

JSON has no `Vector3`, so property values may be plain JSON, or a **tagged
object** `{ "$type": ..., "value": ... }`:

| Roblox type | JSON |
| --- | --- |
| number/string/bool | `5`, `"hi"`, `true` |
| `Vector3` | `{ "$type": "Vector3", "value": [0, 10, 0] }` |
| `Vector2` | `{ "$type": "Vector2", "value": [1, 2] }` |
| `Color3` (0–1) | `{ "$type": "Color3", "value": [1, 0, 0] }` |
| `UDim` | `{ "$type": "UDim", "value": [0.5, 20] }` |
| `UDim2` | `{ "$type": "UDim2", "value": [0.5, 20, 0.5, 0] }` |
| `CFrame` | `{ "$type": "CFrame", "value": [x,y,z, ...12 components] }` |
| `BrickColor` | `{ "$type": "BrickColor", "value": "Bright red" }` |
| `EnumItem` | `{ "$type": "EnumItem", "enum": "Material", "name": "Neon" }` |
| `Instance` ref | `{ "$type": "Instance", "path": "game.Workspace.Part" }` |

Reads (`get_properties`, `get_tree`, `run_luau` return values) come back in the
same tagged form.

### Example

```jsonc
// create_instance
{
  "className": "Part",
  "parent": "game.Workspace",
  "name": "GlowBlock",
  "properties": {
    "Anchored": true,
    "Position": { "$type": "Vector3", "value": [0, 10, 0] },
    "Color":    { "$type": "Color3", "value": [1, 0, 0] },
    "Material": { "$type": "EnumItem", "enum": "Material", "name": "Neon" }
  }
}
```

## Transactions (`batch`)

`batch` runs a list of commands in order as **one transaction**: all their
mutations collapse into a single **Ctrl+Z**, and if any command fails, every
change made earlier in the batch is reverted (via a cancelled
`ChangeHistoryService` recording) and an error is returned. On success you get
back each command's result, in order.

```jsonc
// batch
{
  "commands": [
    { "type": "create_instance", "args": { "className": "Folder", "parent": "game.Workspace", "name": "Arena" } },
    { "type": "create_instance", "args": { "className": "Part", "parent": "game.Workspace.Arena", "name": "Floor",
        "properties": { "Anchored": true, "Size": { "$type": "Vector3", "value": [64, 1, 64] } } } },
    { "type": "set_properties", "args": { "path": "Workspace.Arena.Floor",
        "properties": { "Color": { "$type": "Color3", "value": [0.2, 0.2, 0.2] } } } }
  ]
}
```

If, say, the third command referenced a bad path, the `Arena` folder and
`Floor` part from the first two would be rolled back — nothing is left
half-built. `batch` cannot be nested.

## Script surgery

For anything beyond a whole-file rewrite, work by line — it's far cheaper than
shipping entire scripts back and forth, and it keeps unrelated code untouched.

- **`read_script`** returns `numberedSource` (`1: local x = …`), `lineCount`, and
  `sourceLength`. Pass `startLine`/`endLine` to read a slice; without a range a
  large script is truncated to the first 400 lines with a `note` on how to page.
- **`grep_scripts`** finds where something lives — `{ path, line, text }` matches
  across every script under a path (literal by default; `plain=false` for a Luau
  pattern).
- **`edit_script_lines`** / **`insert_script_lines`** / **`delete_script_lines`**
  change a line range, insert before a line (`lineCount+1` appends), or remove a
  range. All are editor-safe (`ScriptEditorService`) and undoable, and are
  blocked by [read-only mode](#read-only-mode).

Typical loop: `grep_scripts` to locate → `read_script` a range for context →
`edit_script_lines` the exact lines.

## Tags & attributes

`add_tag` / `remove_tag` / `get_tags` / `get_tagged` wrap **CollectionService**,
and `set_attribute` / `get_attributes` / `delete_attribute` cover instance
**attributes** (values use the same [datatype encoding](#encoding-roblox-datatypes)
as properties). The mutating ones are undoable and blocked by read-only mode.

## Reference data

Two tools pull official Roblox data **server-side** — they don't touch the
Studio bridge, so they work even when nothing is connected:

- **`get_roblox_docs`** returns an engine reference page as markdown (from
  `create.roblox.com`) so the agent can check real API semantics instead of
  guessing. Names are case-sensitive PascalCase; categories: `classes` (default),
  `enums`, `datatypes`, `libraries`, `globals`.
- **`get_class_info`** lists a class's properties (with value types), methods,
  events, and callbacks from the official **API dump**, walking the full
  inheritance chain — so `get_class_info Part` includes `Anchored` (from
  `BasePart`), not just `Part`'s own members. Results are cached for a day.

```jsonc
// grep_scripts → read_script → edit_script_lines
{ "pattern": "PlayerAdded" }                                   // find it
{ "path": "ServerScriptService.Main", "startLine": 40, "endLine": 60 }  // read around it
{ "path": "ServerScriptService.Main", "startLine": 47, "endLine": 47,
  "source": "\tprint(\"Welcome, \" .. player.DisplayName)" }   // patch one line
```

## Paths

Paths are resolved from `game`. Both dots and slashes work, and the leading
`game` is optional:

```
game.Workspace.Baseplate
Workspace/Baseplate
ServerScriptService.Main
```

The first hop off `game` is resolved with `game:GetService(...)` when possible.

## Plugin layout

The Studio plugin is a Rojo project. `plugin/default.project.json` maps
`plugin/src/` into a single `Script` (the plugin) with ModuleScript children:

```
plugin/
├── default.project.json   # Rojo project → builds RobloxStudioMCP.rbxm
└── src/
    ├── init.server.lua     # entry: dispatch, poll loop, toolbar (Connect / Read-only)
    ├── Config.lua          # server URL, reconnect delay, mutating-command set
    ├── Paths.lua           # "game.Workspace.Part" → Instance resolution
    ├── Encoding.lua        # Luau ⇄ JSON "$type" tagged datatypes
    └── Handlers.lua        # one function per command (run_luau, create_instance, …)
```

`init.server.lua` owns the stateful/IO pieces (networking, undo recording,
read-only gating, toolbar), and `Handlers` are otherwise pure command logic.

## Configuration

| Env var | Default | Description |
| --- | --- | --- |
| `ROBLOX_STUDIO_MCP_PORT` | `44755` | Port for the local plugin bridge. |
| `ROBLOX_STUDIO_MCP_READONLY` | _(unset)_ | When set to anything other than `""`, `0`, or `false`, blocks all mutating tools. See [Read-only mode](#read-only-mode). |
| `ROBLOX_STUDIO_MCP_TOKEN` | _(unset)_ | Optional shared secret required on every plugin request. Also read from `~/.roblox-studio-mcp/token`. See [Security](#security-notes). |

If you change the port, also update `SERVER_URL` at the top of the plugin.

## Read-only mode

A safety switch that blocks every **mutating** tool — `create_instance`,
`set_properties`, `delete_instance`, `edit_script`, and `batch` — while leaving
the read tools (`get_tree`, `get_properties`, `get_selection`, `read_script`,
`run_luau`) available. Useful when you want the assistant to inspect a place but
not change it. It's enforced in **two independent places** — either one blocks a
mutation:

- **Server (launch-time lock).** Start the server with
  `ROBLOX_STUDIO_MCP_READONLY=1`. Mutating tools are refused before the request
  ever reaches Studio, and the server logs `READ-ONLY mode` on startup.
- **Plugin (runtime toggle).** Click **MCP ▸ Read-only** in Studio. While active,
  the plugin refuses mutating commands no matter what the client sends. Reads
  still work. This is authoritative — it's enforced at the point of execution,
  under the control of whoever is at the Studio session.

> Note: `run_luau` is a read tool for gating purposes, but it can execute
> arbitrary Luau — including code that mutates the DataModel. Read-only mode does
> **not** sandbox `run_luau`; use the plugin toggle plus your own review if you
> need a hard guarantee against changes.

## Security notes

- The bridge binds to `127.0.0.1` only.
- **Origin/Host guard (always on).** The bridge rejects any request carrying an
  `Origin` header or a non-loopback `Host` — so a web page in your browser can't
  drive Studio via `fetch()` (DNS-rebinding / CSRF), while the plugin's plain
  localhost requests pass.
- **Optional shared-secret token.** Set `ROBLOX_STUDIO_MCP_TOKEN` (or create
  `~/.roblox-studio-mcp/token`) and every plugin request must present it
  (checked with a constant-time compare). Give the plugin the same value once,
  from the Studio **command bar**:

  ```lua
  plugin:SetSetting("MCPToken", "your-token-here")
  ```

  Leave it unset (the default) and the loopback + Origin/Host guard still apply.
- `run_luau` executes arbitrary Luau **in the Studio/plugin context**. Only
  connect Studio to an MCP client you trust, and review what the assistant does.
- Mutations are undoable via Studio's history.
- [Read-only mode](#read-only-mode) blocks the dedicated mutating tools when you
  want look-but-don't-touch access.

## Version handshake

On every poll the plugin sends its version and a per-session id; the server
replies with its own version and whether it has seen this session before. The
plugin prints a Studio warning if the two versions disagree (so a stale plugin
or server is obvious), and automatically re-registers if it detects the server
was restarted underneath it.

## Troubleshooting

- **"No Roblox Studio plugin is connected."** The plugin does **not** auto-connect
  when Studio starts — click **MCP ▸ Connect** on the toolbar after opening your
  place. The button is a toggle; make sure it's active.
- **Edited the plugin but nothing changed?** Rebuild the `.rbxm` from
  `plugin/src/` (`rojo build …`) and note that Studio doesn't hot-reload local
  plugins — fully restart Studio, then click **Connect** again.
- **HTTP requests blocked.** Enable **Game Settings ▸ Security ▸ Allow HTTP
  Requests** and reconnect (the plugin also tries to set this for you).
- **Deletes should be undoable.** `delete_instance` re-parents to `nil` inside a
  `ChangeHistoryService` recording, so a single **Ctrl+Z** restores the instance.
  (`Instance:Destroy()` would lock the instance and make the delete unrecoverable.)
- **Mutations keep getting refused ("Read-only mode is enabled")?** Either the
  **MCP ▸ Read-only** toggle is active in Studio, or the server was started with
  `ROBLOX_STUDIO_MCP_READONLY` set. See [Read-only mode](#read-only-mode).
- **"Version mismatch" warning in Studio output?** The plugin and server builds
  differ — rebuild/reinstall the plugin (`rojo build …`, restart Studio) or
  restart the server so both are the same version.
- **"Server rejected the connection (HTTP 401/403)"?** A token is configured on
  the server but the plugin's `MCPToken` setting is missing or wrong — set it to
  match `ROBLOX_STUDIO_MCP_TOKEN` (see [Security](#security-notes)).
- **`eval_server_runtime` says "No live server peer"?** No playtest is running —
  press **Play (F5)** and check `get_instances` for a `server` peer.
- **`eval_server_runtime` fails to compile / "LoadStringEnabled"?** Tick
  `ServerScriptService.LoadStringEnabled` in the Properties panel, and make sure
  **Allow HTTP Requests** is on. See [Runtime eval](#runtime-eval-live-debugging).

## Roadmap

### Done

- [x] Verified in real Studio
- [x] Selection-aware tools (operate on the current Studio selection)
- [x] Output/`print` capture for `run_luau`
- [x] Batch/transaction commands
- [x] Optional read-only mode
- [x] Rojo-friendly project layout for the plugin
- [x] Multi-window instance routing
- [x] Live server-VM eval during playtests (`eval_server_runtime`)

### Planned

- [ ] Client-VM eval + multiplayer peer roles
- [ ] Runtime log capture per peer

## Contributing

Issues and PRs welcome. Run `npm run build` (server) before pushing; CI builds on
Node 20. When changing the plugin, edit the modules in `plugin/src/` and rebuild
the `.rbxm` with `rojo build plugin/default.project.json -o RobloxStudioMCP.rbxm`.

## License

[MIT](./LICENSE) © Emir Coban