# Directory Structure
```
├── .cargo
│ └── config.toml
├── .github
│ └── workflows
│ ├── build.yml
│ ├── checks.yml
│ └── security-scan.yml
├── .gitignore
├── build.rs
├── Cargo.lock
├── Cargo.toml
├── CODEOWNERS
├── LICENSE
├── MCP-Server.png
├── plugin
│ ├── .gitignore
│ ├── .luaurc
│ ├── .vscode
│ │ ├── extensions.json
│ │ └── settings.json
│ ├── default.project.json
│ ├── foreman.toml
│ ├── README.md
│ ├── selene.toml
│ ├── src
│ │ ├── Main.server.luau
│ │ ├── MockWebSocketService.luau
│ │ ├── Tools
│ │ │ ├── InsertModel.luau
│ │ │ └── RunCode.luau
│ │ └── Types.luau
│ └── stylua.toml
├── README.md
├── src
│ ├── error.rs
│ ├── install.rs
│ ├── main.rs
│ └── rbx_studio_server.rs
└── util
├── App.entitlements
├── Certificates.p12
├── config.windows.json.in
├── sign.macos.sh
└── sign.windows.ps1
```
# Files
--------------------------------------------------------------------------------
/plugin/.luaurc:
--------------------------------------------------------------------------------
```
{
"languageMode": "strict"
}
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
target/
**/*.rs.bk
*.pdb
.idea/
.vs/
output/
```
--------------------------------------------------------------------------------
/plugin/.gitignore:
--------------------------------------------------------------------------------
```
build/
node_modules/
.DS_Store
sourcemap.json
```
--------------------------------------------------------------------------------
/plugin/README.md:
--------------------------------------------------------------------------------
```markdown
# Roblox MCP Studio Plugin
This plugin is built and installed as part of the main repo. You can use [rojo](https://rojo.space/) to build it separately as well.
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Roblox Studio MCP Server
This repository contains a reference implementation of the Model Context Protocol (MCP) that enables
communication between Roblox Studio via a plugin and [Claude Desktop](https://claude.ai/download) or [Cursor](https://www.cursor.com/).
It consists of the following Rust-based components, which communicate through internal shared
objects.
- A web server built on `axum` that a Studio plugin long polls.
- A `rmcp` server that talks to Claude via `stdio` transport.
When LLM requests to run a tool, the plugin will get a request through the long polling and post a
response. It will cause responses to be sent to the Claude app.
**Please note** that this MCP server will be accessed by third-party tools, allowing them to modify
and read the contents of your opened place. Third-party data handling and privacy practices are
subject to their respective terms and conditions.

The setup process also contains a short plugin installation and Claude Desktop configuration script.
## Setup
### Install with release binaries
This MCP Server supports pretty much any MCP Client but will automatically set up only [Claude Desktop](https://claude.ai/download) and [Cursor](https://www.cursor.com/) if found.
To set up automatically:
1. Ensure you have [Roblox Studio](https://create.roblox.com/docs/en-us/studio/setup),
and [Claude Desktop](https://claude.ai/download)/[Cursor](https://www.cursor.com/) installed and started at least once.
1. Exit MCP Clients and Roblox Studio if they are running.
1. Download and run the installer:
1. Go to the [releases](https://github.com/Roblox/studio-rust-mcp-server/releases) page and
download the latest release for your platform.
1. Unzip the downloaded file if necessary and run the installer.
1. Restart Claude/Cursor and Roblox Studio if they are running.
### Setting up manually
To set up manually add following to your MCP Client config:
```json
{
"mcpServers": {
"Roblox Studio": {
"args": [
"--stdio"
],
"command": "Path-to-downloaded\\rbx-studio-mcp.exe"
}
}
}
```
On macOS the path would be something like `"/Applications/RobloxStudioMCP.app/Contents/MacOS/rbx-studio-mcp"` if you move the app to the Applications directory.
### Build from source
To build and install the MCP reference implementation from this repository's source code:
1. Ensure you have [Roblox Studio](https://create.roblox.com/docs/en-us/studio/setup) and
[Claude Desktop](https://claude.ai/download) installed and started at least once.
1. Exit Claude and Roblox Studio if they are running.
1. [Install](https://www.rust-lang.org/tools/install) Rust.
1. Download or clone this repository.
1. Run the following command from the root of this repository.
```sh
cargo run
```
This command carries out the following actions:
- Builds the Rust MCP server app.
- Sets up Claude to communicate with the MCP server.
- Builds and installs the Studio plugin to communicate with the MCP server.
After the command completes, the Studio MCP Server is installed and ready for your prompts from
Claude Desktop.
## Verify setup
To make sure everything is set up correctly, follow these steps:
1. In Roblox Studio, click on the **Plugins** tab and verify that the MCP plugin appears. Clicking on
the icon toggles the MCP communication with Claude Desktop on and off, which you can verify in
the Roblox Studio console output.
1. In the console, verify that `The MCP Studio plugin is ready for prompts.` appears in the output.
Clicking on the plugin's icon toggles MCP communication with Claude Desktop on and off,
which you can also verify in the console output.
1. Verify that Claude Desktop is correctly configured by clicking on the hammer icon for MCP tools
beneath the text field where you enter prompts. This should open a window with the list of
available Roblox Studio tools (`insert_model` and `run_code`).
**Note**: You can fix common issues with setup by restarting Studio and Claude Desktop. Claude
sometimes is hidden in the system tray, so ensure you've exited it completely.
## Send requests
1. Open a place in Studio.
1. Type a prompt in Claude Desktop and accept any permissions to communicate with Studio.
1. Verify that the intended action is performed in Studio by checking the console, inspecting the
data model in Explorer, or visually confirming the desired changes occurred in your place.
```
--------------------------------------------------------------------------------
/plugin/selene.toml:
--------------------------------------------------------------------------------
```toml
std = "roblox"
```
--------------------------------------------------------------------------------
/plugin/stylua.toml:
--------------------------------------------------------------------------------
```toml
[sort_requires]
enabled = true
```
--------------------------------------------------------------------------------
/plugin/foreman.toml:
--------------------------------------------------------------------------------
```toml
[tools]
rojo = { source = "rojo-rbx/rojo", version = "7.4.4" }
```
--------------------------------------------------------------------------------
/plugin/default.project.json:
--------------------------------------------------------------------------------
```json
{
"name": "MCPStudioPlugin",
"tree": {
"$path": "src"
}
}
```
--------------------------------------------------------------------------------
/.cargo/config.toml:
--------------------------------------------------------------------------------
```toml
[target.x86_64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]
```
--------------------------------------------------------------------------------
/plugin/.vscode/extensions.json:
--------------------------------------------------------------------------------
```json
{
"recommendations": [
"JohnnyMorganz.luau-lsp",
"JohnnyMorganz.stylua",
"Kampfkarren.selene-vscode"
]
}
```
--------------------------------------------------------------------------------
/plugin/.vscode/settings.json:
--------------------------------------------------------------------------------
```json
{
"stylua.targetReleaseVersion": "latest",
"[lua]": {
"editor.defaultFormatter": "JohnnyMorganz.stylua",
"editor.formatOnSave": true,
},
"[luau]": {
"editor.defaultFormatter": "JohnnyMorganz.stylua",
"editor.formatOnSave": true,
},
}
```
--------------------------------------------------------------------------------
/.github/workflows/security-scan.yml:
--------------------------------------------------------------------------------
```yaml
name: Security Scan
on:
pull_request:
push:
branches:
- main
jobs:
security:
name: OSS Security SAST
uses: Roblox/security-workflows/.github/workflows/oss-security-sast.yaml@main
with:
skip-ossf: true
secrets:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
ROBLOX_SEMGREP_GHC_POC_APP_TOKEN: ${{ secrets.ROBLOX_SEMGREP_GHC_POC_APP_TOKEN }}
```
--------------------------------------------------------------------------------
/build.rs:
--------------------------------------------------------------------------------
```rust
use librojo::cli;
fn main() {
let out_dir = std::env::var_os("OUT_DIR").unwrap();
let dest_path = std::path::PathBuf::from(&out_dir).join("MCPStudioPlugin.rbxm");
eprintln!("Rebuilding plugin: {dest_path:?}");
let options = cli::Options {
global: cli::GlobalOptions {
verbosity: 1,
color: cli::ColorChoice::Always,
},
subcommand: cli::Subcommand::Build(cli::BuildCommand {
project: std::path::PathBuf::from("plugin"),
output: Some(dest_path),
plugin: None,
watch: false,
}),
};
options.run().unwrap();
println!("cargo:rerun-if-changed=plugin");
}
```
--------------------------------------------------------------------------------
/util/sign.windows.ps1:
--------------------------------------------------------------------------------
```
if ($env:SIGNING_ACCOUNT) {
choco install dotnet-8.0-runtime --no-progress
nuget install Microsoft.Windows.SDK.BuildTools -Version 10.0.22621.3233 -x
nuget install Microsoft.Trusted.Signing.Client -Version 1.0.53 -x
(Get-Content .\util\config.windows.json.in) -replace "SIGNING_ACCOUNT", $env:SIGNING_ACCOUNT | Out-File -encoding ASCII .\util\config.windows.json
.\Microsoft.Windows.SDK.BuildTools\bin\10.0.22621.0\x86\signtool.exe sign /v /debug /fd SHA256 /tr "http://timestamp.acs.microsoft.com" /td SHA256 /dlib .\Microsoft.Trusted.Signing.Client\bin\x86\Azure.CodeSigning.Dlib.dll /dmdf .\util\config.windows.json .\target\release\rbx-studio-mcp.exe
}
copy target\release\rbx-studio-mcp.exe output\
```
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
```yaml
name: Code Quality Checks
on:
- push
- pull_request
jobs:
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Clippy
run: cargo clippy -- -D warnings
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: cargo fmt -- --check
selene:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Selene
working-directory: plugin
run: |
cargo install selene
selene .
StyLua:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: StyLua
working-directory: plugin
run: |
cargo install stylua --features luau
stylua . --check
```
--------------------------------------------------------------------------------
/src/error.rs:
--------------------------------------------------------------------------------
```rust
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
pub type Result<T, E = Report> = color_eyre::Result<T, E>;
pub struct Report(color_eyre::Report);
impl std::fmt::Debug for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::fmt::Display for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<E> From<E> for Report
where
E: Into<color_eyre::Report>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
impl IntoResponse for Report {
fn into_response(self) -> Response {
let err = self.0;
let err_string = format!("{err:?}");
tracing::error!("{err_string}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong".to_string(),
)
.into_response()
}
}
```
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
```toml
[package]
name = "rbx-studio-mcp"
version = "0.1.0"
edition = "2021"
publish = false
license = "MIT"
[dependencies]
rmcp = { version = "0.3", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
axum = { version = "0.8", features = ["macros"] }
reqwest = { version = "0.12", features = ["json"] }
color-eyre = "0.6"
clap = { version = "4.5.37", features = ["derive"] }
roblox_install = "1.0.0"
[target.'cfg(target_os = "macos")'.dependencies]
native-dialog = "0.8.8"
security-translocate = "0.2.1"
core-foundation = "0.10.0"
[build-dependencies]
rojo = "7.4.4"
[package.metadata.bundle]
name = "RobloxStudioMCP"
description = "Model Context Protocol server for Roblox Studio"
identifier = "com.rbx-mcp.server"
```
--------------------------------------------------------------------------------
/util/sign.macos.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
set -ex
BUNDLE_DIR=target/aarch64-apple-darwin/release/bundle
if [ -n "$APPLE_API_KEY_CONTENT" ]
then
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
function cleanup() {
security delete-keychain "$KEYCHAIN_PATH" || true
}
trap cleanup EXIT
KEYCHAIN_PASSWORD=$(head -1 /dev/random | md5)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import util/Certificates.p12 -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | cut -d' ' -f4 | tail -1)
echo "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY"
codesign -s "$IDENTITY" -v -f -o runtime --deep -i com.rbx-mcp.server --timestamp --entitlements util/App.entitlements --generate-entitlement-der "$BUNDLE_DIR/osx/RobloxStudioMCP.app"
ditto -c -k $BUNDLE_DIR/osx $BUNDLE_DIR/bund.zip
xcrun notarytool submit -k "$APPLE_API_KEY" -d "$APPLE_API_KEY_ID" -i "$APPLE_API_ISSUER" --wait --progress $BUNDLE_DIR/bund.zip
xcrun stapler staple "$BUNDLE_DIR/osx/RobloxStudioMCP.app"
fi
ditto -c -k $BUNDLE_DIR/osx output/macOS-rbx-studio-mcp.zip
```
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
```rust
use axum::routing::{get, post};
use clap::Parser;
use color_eyre::eyre::Result;
use rbx_studio_server::*;
use rmcp::ServiceExt;
use std::io;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing_subscriber::{self, EnvFilter};
mod error;
mod install;
mod rbx_studio_server;
/// Simple MCP proxy for Roblox Studio
/// Run without arguments to install the plugin
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Args {
/// Run as MCP server on stdio
#[arg(short, long)]
stdio: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(io::stderr)
.with_target(false)
.with_thread_ids(true)
.init();
let args = Args::parse();
if !args.stdio {
return install::install().await;
}
tracing::debug!("Debug MCP tracing enabled");
let server_state = Arc::new(Mutex::new(AppState::new()));
let (close_tx, close_rx) = tokio::sync::oneshot::channel();
let listener =
tokio::net::TcpListener::bind((Ipv4Addr::new(127, 0, 0, 1), STUDIO_PLUGIN_PORT)).await;
let server_state_clone = Arc::clone(&server_state);
let server_handle = if let Ok(listener) = listener {
let app = axum::Router::new()
.route("/request", get(request_handler))
.route("/response", post(response_handler))
.route("/proxy", post(proxy_handler))
.with_state(server_state_clone);
tracing::info!("This MCP instance is HTTP server listening on {STUDIO_PLUGIN_PORT}");
tokio::spawn(async {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
_ = close_rx.await;
})
.await
.unwrap();
})
} else {
tracing::info!("This MCP instance will use proxy since port is busy");
tokio::spawn(async move {
dud_proxy_loop(server_state_clone, close_rx).await;
})
};
// Create an instance of our counter router
let service = RBXStudioServer::new(Arc::clone(&server_state))
.serve(rmcp::transport::stdio())
.await
.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?;
service.waiting().await?;
close_tx.send(()).ok();
tracing::info!("Waiting for web server to gracefully shutdown");
server_handle.await.ok();
tracing::info!("Bye!");
Ok(())
}
```
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
```yaml
name: Build
on:
- push
- pull_request
env:
CARGO_PKG_VERSION: "0.2.${{ github.run_number }}"
permissions:
contents: write
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
cargo install cargo-bundle cargo-edit
cargo set-version --workspace "$CARGO_PKG_VERSION"
rustup target add x86_64-apple-darwin aarch64-apple-darwin
cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin
cargo bundle --release --target aarch64-apple-darwin
lipo -create target/aarch64-apple-darwin/release/rbx-studio-mcp target/x86_64-apple-darwin/release/rbx-studio-mcp -output "target/aarch64-apple-darwin/release/bundle/osx/RobloxStudioMCP.app/Contents/MacOS/rbx-studio-mcp"
- name: Sign and Notarize macOS binary
run: ./util/sign.macos.sh
env:
APPLE_API_KEY: key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: macOS-rbx-studio-mcp
path: output/macOS-rbx-studio-mcp.zip
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
cargo install cargo-edit
cargo set-version --workspace "$CARGO_PKG_VERSION"
mkdir output
cargo build --release
- name: Sign windows binary
run: ./util/sign.windows.ps1
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
SIGNING_ACCOUNT: ${{ secrets.SIGNING_ACCOUNT }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Windows-rbx-studio-mcp
path: output/rbx-studio-mcp.exe
release:
runs-on: ubuntu-latest
if: ${{ github.ref_name == github.event.repository.default_branch }}
needs: [build-macos, build-windows]
steps:
- run: mkdir -p output
- uses: actions/download-artifact@v4
with:
path: output
merge-multiple: true
- name: Create release
uses: Roblox-ActionsCache/softprops-action-gh-release@v1
with:
tag_name: v${{ env.CARGO_PKG_VERSION }}
release_name: Release v${{ env.CARGO_PKG_VERSION }} }}
files: output/*
```
--------------------------------------------------------------------------------
/src/install.rs:
--------------------------------------------------------------------------------
```rust
use color_eyre::eyre::{eyre, Result, WrapErr};
use color_eyre::Help;
use roblox_install::RobloxStudio;
use serde_json::{json, Value};
use std::fs::File;
use std::io::BufReader;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::vec;
use std::{env, fs, io};
fn get_message(successes: String) -> String {
format!("Roblox Studio MCP is ready to go.
Please restart Studio and MCP clients to apply the changes.
MCP Clients set up:
{successes}
Note: connecting a third-party LLM to Roblox Studio via an MCP server will share your data with that external service provider. Please review their privacy practices carefully before proceeding.
To uninstall, delete the MCPStudioPlugin.rbxm from your Plugins directory.")
}
// returns OS dependant claude_desktop_config.json path
fn get_claude_config() -> Result<PathBuf> {
let home_dir = env::var_os("HOME");
let config_path = if cfg!(target_os = "macos") {
Path::new(&home_dir.unwrap())
.join("Library/Application Support/Claude/claude_desktop_config.json")
} else if cfg!(target_os = "windows") {
let app_data =
env::var_os("APPDATA").ok_or_else(|| eyre!("Could not find APPDATA directory"))?;
Path::new(&app_data)
.join("Claude")
.join("claude_desktop_config.json")
} else {
return Err(eyre!("Unsupported operating system"));
};
Ok(config_path)
}
fn get_cursor_config() -> Result<PathBuf> {
let home_dir = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.unwrap();
Ok(Path::new(&home_dir).join(".cursor").join("mcp.json"))
}
#[cfg(target_os = "macos")]
fn get_exe_path() -> Result<PathBuf> {
use core_foundation::url::CFURL;
let local_path = env::current_exe()?;
let local_path_cref = CFURL::from_path(local_path, false).unwrap();
let un_relocated = security_translocate::create_original_path_for_url(local_path_cref.clone())
.or_else(move |_| Ok::<CFURL, io::Error>(local_path_cref.clone()))?;
let ret = un_relocated.to_path().unwrap();
Ok(ret)
}
#[cfg(not(target_os = "macos"))]
fn get_exe_path() -> io::Result<PathBuf> {
env::current_exe()
}
pub fn install_to_config<'a>(
config_path: Result<PathBuf>,
exe_path: &Path,
name: &'a str,
) -> Result<&'a str> {
let config_path = config_path?;
let mut config: serde_json::Map<String, Value> = {
if !config_path.exists() {
let mut file = File::create(&config_path).map_err(|e| {
eyre!("Could not create {name} config file at {config_path:?}: {e:#?}")
})?;
file.write_all(serde_json::to_string(&serde_json::Map::new())?.as_bytes())?;
}
let config_file = File::open(&config_path)
.map_err(|error| eyre!("Could not read or create {name} config file: {error:#?}"))?;
let reader = BufReader::new(config_file);
serde_json::from_reader(reader)?
};
if !matches!(config.get("mcpServers"), Some(Value::Object(_))) {
config.insert("mcpServers".to_string(), json!({}));
}
config["mcpServers"]["Roblox Studio"] = json!({
"command": &exe_path,
"args": [
"--stdio"
]
});
let mut file = File::create(&config_path)?;
file.write_all(serde_json::to_string_pretty(&config)?.as_bytes())
.map_err(|e| eyre!("Could not write to {name} config file at {config_path:?}: {e:#?}"))?;
println!("Installed MCP Studio plugin to {name} config {config_path:?}");
Ok(name)
}
async fn install_internal() -> Result<String> {
let plugin_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/MCPStudioPlugin.rbxm"));
let studio = RobloxStudio::locate()?;
let plugins = studio.plugins_path();
if let Err(err) = fs::create_dir(plugins) {
if err.kind() != io::ErrorKind::AlreadyExists {
return Err(err.into());
}
}
let output_plugin = Path::new(&plugins).join("MCPStudioPlugin.rbxm");
{
let mut file = File::create(&output_plugin).wrap_err_with(|| {
format!(
"Could write Roblox Plugin file at {}",
output_plugin.display()
)
})?;
file.write_all(plugin_bytes)?;
}
println!(
"Installed Roblox Studio plugin to {}",
output_plugin.display()
);
let this_exe = get_exe_path()?;
let mut errors = vec![];
let results = vec![
install_to_config(get_claude_config(), &this_exe, "Claude"),
install_to_config(get_cursor_config(), &this_exe, "Cursor"),
];
let successes: Vec<_> = results
.into_iter()
.filter_map(|r| r.map_err(|e| errors.push(e)).ok())
.collect();
if successes.is_empty() {
let error = errors.into_iter().fold(
eyre!("Failed to install to either Claude or Cursor"),
|report, e| report.note(e),
);
return Err(error);
}
println!();
let msg = get_message(successes.join("\n"));
println!("{msg}");
Ok(msg)
}
#[cfg(target_os = "windows")]
pub async fn install() -> Result<()> {
use std::process::Command;
if let Err(e) = install_internal().await {
tracing::error!("Failed initialize Roblox MCP: {:#}", e);
}
let _ = Command::new("cmd.exe").arg("/c").arg("pause").status();
Ok(())
}
#[cfg(target_os = "macos")]
pub async fn install() -> Result<()> {
use native_dialog::{DialogBuilder, MessageLevel};
let alert_builder = match install_internal().await {
Err(e) => DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_text(format!("Errors occurred: {e:#}")),
Ok(msg) => DialogBuilder::message()
.set_level(MessageLevel::Info)
.set_text(msg),
};
let _ = alert_builder.set_title("Roblox Studio MCP").alert().show();
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub async fn install() -> Result<()> {
install_internal().await?;
Ok(())
}
```
--------------------------------------------------------------------------------
/src/rbx_studio_server.rs:
--------------------------------------------------------------------------------
```rust
use crate::error::Result;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::State, Json};
use color_eyre::eyre::{Error, OptionExt};
use rmcp::{
handler::server::tool::Parameters,
model::{
CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
},
schemars, tool, tool_handler, tool_router, ErrorData, ServerHandler,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::future::Future;
use std::sync::Arc;
use tokio::sync::oneshot::Receiver;
use tokio::sync::{mpsc, watch, Mutex};
use tokio::time::Duration;
use uuid::Uuid;
pub const STUDIO_PLUGIN_PORT: u16 = 44755;
const LONG_POLL_DURATION: Duration = Duration::from_secs(15);
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ToolArguments {
args: ToolArgumentValues,
id: Option<Uuid>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct RunCommandResponse {
response: String,
id: Uuid,
}
pub struct AppState {
process_queue: VecDeque<ToolArguments>,
output_map: HashMap<Uuid, mpsc::UnboundedSender<Result<String>>>,
waiter: watch::Receiver<()>,
trigger: watch::Sender<()>,
}
pub type PackedState = Arc<Mutex<AppState>>;
impl AppState {
pub fn new() -> Self {
let (trigger, waiter) = watch::channel(());
Self {
process_queue: VecDeque::new(),
output_map: HashMap::new(),
waiter,
trigger,
}
}
}
impl ToolArguments {
fn new(args: ToolArgumentValues) -> (Self, Uuid) {
Self { args, id: None }.with_id()
}
fn with_id(self) -> (Self, Uuid) {
let id = Uuid::new_v4();
(
Self {
args: self.args,
id: Some(id),
},
id,
)
}
}
#[derive(Clone)]
pub struct RBXStudioServer {
state: PackedState,
tool_router: rmcp::handler::server::tool::ToolRouter<Self>,
}
#[tool_handler]
impl ServerHandler for RBXStudioServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation::from_build_env(),
instructions: Some(
"User run_command to query data from Roblox Studio place or to change it"
.to_string(),
),
}
}
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema, Clone)]
struct RunCode {
#[schemars(description = "Code to run")]
command: String,
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema, Clone)]
struct InsertModel {
#[schemars(description = "Query to search for the model")]
query: String,
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema, Clone)]
enum ToolArgumentValues {
RunCode(RunCode),
InsertModel(InsertModel),
}
#[tool_router]
impl RBXStudioServer {
pub fn new(state: PackedState) -> Self {
Self {
state,
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Runs a command in Roblox Studio and returns the printed output. Can be used to both make changes and retrieve information"
)]
async fn run_code(
&self,
Parameters(args): Parameters<RunCode>,
) -> Result<CallToolResult, ErrorData> {
self.generic_tool_run(ToolArgumentValues::RunCode(args))
.await
}
#[tool(
description = "Inserts a model from the Roblox marketplace into the workspace. Returns the inserted model name."
)]
async fn insert_model(
&self,
Parameters(args): Parameters<InsertModel>,
) -> Result<CallToolResult, ErrorData> {
self.generic_tool_run(ToolArgumentValues::InsertModel(args))
.await
}
async fn generic_tool_run(
&self,
args: ToolArgumentValues,
) -> Result<CallToolResult, ErrorData> {
let (command, id) = ToolArguments::new(args);
tracing::debug!("Running command: {:?}", command);
let (tx, mut rx) = mpsc::unbounded_channel::<Result<String>>();
let trigger = {
let mut state = self.state.lock().await;
state.process_queue.push_back(command);
state.output_map.insert(id, tx);
state.trigger.clone()
};
trigger
.send(())
.map_err(|e| ErrorData::internal_error(format!("Unable to trigger send {e}"), None))?;
let result = rx
.recv()
.await
.ok_or(ErrorData::internal_error("Couldn't receive response", None))?;
{
let mut state = self.state.lock().await;
state.output_map.remove_entry(&id);
}
tracing::debug!("Sending to MCP: {result:?}");
match result {
Ok(result) => Ok(CallToolResult::success(vec![Content::text(result)])),
Err(err) => Ok(CallToolResult::error(vec![Content::text(err.to_string())])),
}
}
}
pub async fn request_handler(State(state): State<PackedState>) -> Result<impl IntoResponse> {
let timeout = tokio::time::timeout(LONG_POLL_DURATION, async {
loop {
let mut waiter = {
let mut state = state.lock().await;
if let Some(task) = state.process_queue.pop_front() {
return Ok::<ToolArguments, Error>(task);
}
state.waiter.clone()
};
waiter.changed().await?
}
})
.await;
match timeout {
Ok(result) => Ok(Json(result?).into_response()),
_ => Ok((StatusCode::LOCKED, String::new()).into_response()),
}
}
pub async fn response_handler(
State(state): State<PackedState>,
Json(payload): Json<RunCommandResponse>,
) -> Result<impl IntoResponse> {
tracing::debug!("Received reply from studio {payload:?}");
let mut state = state.lock().await;
let tx = state
.output_map
.remove(&payload.id)
.ok_or_eyre("Unknown ID")?;
Ok(tx.send(Ok(payload.response))?)
}
pub async fn proxy_handler(
State(state): State<PackedState>,
Json(command): Json<ToolArguments>,
) -> Result<impl IntoResponse> {
let id = command.id.ok_or_eyre("Got proxy command with no id")?;
tracing::debug!("Received request to proxy {command:?}");
let (tx, mut rx) = mpsc::unbounded_channel();
{
let mut state = state.lock().await;
state.process_queue.push_back(command);
state.output_map.insert(id, tx);
}
let response = rx.recv().await.ok_or_eyre("Couldn't receive response")??;
{
let mut state = state.lock().await;
state.output_map.remove_entry(&id);
}
tracing::debug!("Sending back to dud: {response:?}");
Ok(Json(RunCommandResponse { response, id }))
}
pub async fn dud_proxy_loop(state: PackedState, exit: Receiver<()>) {
let client = reqwest::Client::new();
let mut waiter = { state.lock().await.waiter.clone() };
while exit.is_empty() {
let entry = { state.lock().await.process_queue.pop_front() };
if let Some(entry) = entry {
let res = client
.post(format!("http://127.0.0.1:{STUDIO_PLUGIN_PORT}/proxy"))
.json(&entry)
.send()
.await;
if let Ok(res) = res {
let tx = {
state
.lock()
.await
.output_map
.remove(&entry.id.unwrap())
.unwrap()
};
let res = res
.json::<RunCommandResponse>()
.await
.map(|r| r.response)
.map_err(Into::into);
tx.send(res).unwrap();
} else {
tracing::error!("Failed to proxy: {res:?}");
};
} else {
waiter.changed().await.unwrap();
}
}
}
```