# 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:
--------------------------------------------------------------------------------
```
1 | {
2 | "languageMode": "strict"
3 | }
4 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | target/
2 | **/*.rs.bk
3 | *.pdb
4 | .idea/
5 | .vs/
6 | output/
7 |
```
--------------------------------------------------------------------------------
/plugin/.gitignore:
--------------------------------------------------------------------------------
```
1 | build/
2 | node_modules/
3 | .DS_Store
4 |
5 | sourcemap.json
6 |
```
--------------------------------------------------------------------------------
/plugin/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Roblox MCP Studio Plugin
2 |
3 | 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.
4 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Roblox Studio MCP Server
2 |
3 | This repository contains a reference implementation of the Model Context Protocol (MCP) that enables
4 | communication between Roblox Studio via a plugin and [Claude Desktop](https://claude.ai/download) or [Cursor](https://www.cursor.com/).
5 | It consists of the following Rust-based components, which communicate through internal shared
6 | objects.
7 |
8 | - A web server built on `axum` that a Studio plugin long polls.
9 | - A `rmcp` server that talks to Claude via `stdio` transport.
10 |
11 | When LLM requests to run a tool, the plugin will get a request through the long polling and post a
12 | response. It will cause responses to be sent to the Claude app.
13 |
14 | **Please note** that this MCP server will be accessed by third-party tools, allowing them to modify
15 | and read the contents of your opened place. Third-party data handling and privacy practices are
16 | subject to their respective terms and conditions.
17 |
18 | 
19 |
20 | The setup process also contains a short plugin installation and Claude Desktop configuration script.
21 |
22 | ## Setup
23 |
24 | ### Install with release binaries
25 |
26 | 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.
27 |
28 | To set up automatically:
29 |
30 | 1. Ensure you have [Roblox Studio](https://create.roblox.com/docs/en-us/studio/setup),
31 | and [Claude Desktop](https://claude.ai/download)/[Cursor](https://www.cursor.com/) installed and started at least once.
32 | 1. Exit MCP Clients and Roblox Studio if they are running.
33 | 1. Download and run the installer:
34 | 1. Go to the [releases](https://github.com/Roblox/studio-rust-mcp-server/releases) page and
35 | download the latest release for your platform.
36 | 1. Unzip the downloaded file if necessary and run the installer.
37 | 1. Restart Claude/Cursor and Roblox Studio if they are running.
38 |
39 | ### Setting up manually
40 |
41 | To set up manually add following to your MCP Client config:
42 |
43 | ```json
44 | {
45 | "mcpServers": {
46 | "Roblox Studio": {
47 | "args": [
48 | "--stdio"
49 | ],
50 | "command": "Path-to-downloaded\\rbx-studio-mcp.exe"
51 | }
52 | }
53 | }
54 | ```
55 |
56 | 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.
57 |
58 | ### Build from source
59 |
60 | To build and install the MCP reference implementation from this repository's source code:
61 |
62 | 1. Ensure you have [Roblox Studio](https://create.roblox.com/docs/en-us/studio/setup) and
63 | [Claude Desktop](https://claude.ai/download) installed and started at least once.
64 | 1. Exit Claude and Roblox Studio if they are running.
65 | 1. [Install](https://www.rust-lang.org/tools/install) Rust.
66 | 1. Download or clone this repository.
67 | 1. Run the following command from the root of this repository.
68 | ```sh
69 | cargo run
70 | ```
71 | This command carries out the following actions:
72 | - Builds the Rust MCP server app.
73 | - Sets up Claude to communicate with the MCP server.
74 | - Builds and installs the Studio plugin to communicate with the MCP server.
75 |
76 | After the command completes, the Studio MCP Server is installed and ready for your prompts from
77 | Claude Desktop.
78 |
79 | ## Verify setup
80 |
81 | To make sure everything is set up correctly, follow these steps:
82 |
83 | 1. In Roblox Studio, click on the **Plugins** tab and verify that the MCP plugin appears. Clicking on
84 | the icon toggles the MCP communication with Claude Desktop on and off, which you can verify in
85 | the Roblox Studio console output.
86 | 1. In the console, verify that `The MCP Studio plugin is ready for prompts.` appears in the output.
87 | Clicking on the plugin's icon toggles MCP communication with Claude Desktop on and off,
88 | which you can also verify in the console output.
89 | 1. Verify that Claude Desktop is correctly configured by clicking on the hammer icon for MCP tools
90 | beneath the text field where you enter prompts. This should open a window with the list of
91 | available Roblox Studio tools (`insert_model` and `run_code`).
92 |
93 | **Note**: You can fix common issues with setup by restarting Studio and Claude Desktop. Claude
94 | sometimes is hidden in the system tray, so ensure you've exited it completely.
95 |
96 | ## Send requests
97 |
98 | 1. Open a place in Studio.
99 | 1. Type a prompt in Claude Desktop and accept any permissions to communicate with Studio.
100 | 1. Verify that the intended action is performed in Studio by checking the console, inspecting the
101 | data model in Explorer, or visually confirming the desired changes occurred in your place.
102 |
```
--------------------------------------------------------------------------------
/plugin/selene.toml:
--------------------------------------------------------------------------------
```toml
1 | std = "roblox"
2 |
```
--------------------------------------------------------------------------------
/plugin/stylua.toml:
--------------------------------------------------------------------------------
```toml
1 | [sort_requires]
2 | enabled = true
3 |
```
--------------------------------------------------------------------------------
/plugin/foreman.toml:
--------------------------------------------------------------------------------
```toml
1 | [tools]
2 | rojo = { source = "rojo-rbx/rojo", version = "7.4.4" }
3 |
```
--------------------------------------------------------------------------------
/plugin/default.project.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "MCPStudioPlugin",
3 | "tree": {
4 | "$path": "src"
5 | }
6 | }
7 |
```
--------------------------------------------------------------------------------
/.cargo/config.toml:
--------------------------------------------------------------------------------
```toml
1 | [target.x86_64-pc-windows-msvc]
2 | rustflags = ["-Ctarget-feature=+crt-static"]
3 |
```
--------------------------------------------------------------------------------
/plugin/.vscode/extensions.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "recommendations": [
3 | "JohnnyMorganz.luau-lsp",
4 | "JohnnyMorganz.stylua",
5 | "Kampfkarren.selene-vscode"
6 | ]
7 | }
8 |
```
--------------------------------------------------------------------------------
/plugin/.vscode/settings.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "stylua.targetReleaseVersion": "latest",
3 | "[lua]": {
4 | "editor.defaultFormatter": "JohnnyMorganz.stylua",
5 | "editor.formatOnSave": true,
6 | },
7 | "[luau]": {
8 | "editor.defaultFormatter": "JohnnyMorganz.stylua",
9 | "editor.formatOnSave": true,
10 | },
11 | }
12 |
```
--------------------------------------------------------------------------------
/.github/workflows/security-scan.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Security Scan
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | security:
11 | name: OSS Security SAST
12 | uses: Roblox/security-workflows/.github/workflows/oss-security-sast.yaml@main
13 | with:
14 | skip-ossf: true
15 | secrets:
16 | GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
17 | ROBLOX_SEMGREP_GHC_POC_APP_TOKEN: ${{ secrets.ROBLOX_SEMGREP_GHC_POC_APP_TOKEN }}
18 |
```
--------------------------------------------------------------------------------
/build.rs:
--------------------------------------------------------------------------------
```rust
1 | use librojo::cli;
2 |
3 | fn main() {
4 | let out_dir = std::env::var_os("OUT_DIR").unwrap();
5 | let dest_path = std::path::PathBuf::from(&out_dir).join("MCPStudioPlugin.rbxm");
6 | eprintln!("Rebuilding plugin: {dest_path:?}");
7 | let options = cli::Options {
8 | global: cli::GlobalOptions {
9 | verbosity: 1,
10 | color: cli::ColorChoice::Always,
11 | },
12 | subcommand: cli::Subcommand::Build(cli::BuildCommand {
13 | project: std::path::PathBuf::from("plugin"),
14 | output: Some(dest_path),
15 | plugin: None,
16 | watch: false,
17 | }),
18 | };
19 | options.run().unwrap();
20 | println!("cargo:rerun-if-changed=plugin");
21 | }
22 |
```
--------------------------------------------------------------------------------
/util/sign.windows.ps1:
--------------------------------------------------------------------------------
```
1 |
2 | if ($env:SIGNING_ACCOUNT) {
3 | choco install dotnet-8.0-runtime --no-progress
4 | nuget install Microsoft.Windows.SDK.BuildTools -Version 10.0.22621.3233 -x
5 | nuget install Microsoft.Trusted.Signing.Client -Version 1.0.53 -x
6 |
7 | (Get-Content .\util\config.windows.json.in) -replace "SIGNING_ACCOUNT", $env:SIGNING_ACCOUNT | Out-File -encoding ASCII .\util\config.windows.json
8 |
9 | .\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
10 | }
11 | copy target\release\rbx-studio-mcp.exe output\
12 |
```
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Code Quality Checks
2 |
3 | on:
4 | - push
5 | - pull_request
6 |
7 | jobs:
8 | clippy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - name: Run Clippy
13 | run: cargo clippy -- -D warnings
14 |
15 | fmt:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Check formatting
20 | run: cargo fmt -- --check
21 |
22 | selene:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | - name: Selene
27 | working-directory: plugin
28 | run: |
29 | cargo install selene
30 | selene .
31 |
32 | StyLua:
33 | runs-on: ubuntu-latest
34 | steps:
35 | - uses: actions/checkout@v4
36 | - name: StyLua
37 | working-directory: plugin
38 | run: |
39 | cargo install stylua --features luau
40 | stylua . --check
41 |
```
--------------------------------------------------------------------------------
/src/error.rs:
--------------------------------------------------------------------------------
```rust
1 | use axum::{
2 | http::StatusCode,
3 | response::{IntoResponse, Response},
4 | };
5 |
6 | pub type Result<T, E = Report> = color_eyre::Result<T, E>;
7 | pub struct Report(color_eyre::Report);
8 |
9 | impl std::fmt::Debug for Report {
10 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11 | self.0.fmt(f)
12 | }
13 | }
14 | impl std::fmt::Display for Report {
15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 | self.0.fmt(f)
17 | }
18 | }
19 |
20 | impl<E> From<E> for Report
21 | where
22 | E: Into<color_eyre::Report>,
23 | {
24 | fn from(err: E) -> Self {
25 | Self(err.into())
26 | }
27 | }
28 |
29 | impl IntoResponse for Report {
30 | fn into_response(self) -> Response {
31 | let err = self.0;
32 | let err_string = format!("{err:?}");
33 | tracing::error!("{err_string}");
34 | (
35 | StatusCode::INTERNAL_SERVER_ERROR,
36 | "Something went wrong".to_string(),
37 | )
38 | .into_response()
39 | }
40 | }
41 |
```
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
```toml
1 | [package]
2 | name = "rbx-studio-mcp"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 | license = "MIT"
7 |
8 | [dependencies]
9 | rmcp = { version = "0.3", features = ["server", "transport-io"] }
10 | tokio = { version = "1", features = ["full"] }
11 | serde = { version = "1.0", features = ["derive"] }
12 | serde_json = "1.0"
13 | tracing = "0.1"
14 | tracing-subscriber = { version = "0.3", features = ["env-filter"] }
15 | uuid = { version = "1", features = ["v4", "serde"] }
16 | axum = { version = "0.8", features = ["macros"] }
17 | reqwest = { version = "0.12", features = ["json"] }
18 | color-eyre = "0.6"
19 | clap = { version = "4.5.37", features = ["derive"] }
20 | roblox_install = "1.0.0"
21 |
22 | [target.'cfg(target_os = "macos")'.dependencies]
23 | native-dialog = "0.8.8"
24 | security-translocate = "0.2.1"
25 | core-foundation = "0.10.0"
26 |
27 | [build-dependencies]
28 | rojo = "7.4.4"
29 |
30 | [package.metadata.bundle]
31 | name = "RobloxStudioMCP"
32 | description = "Model Context Protocol server for Roblox Studio"
33 | identifier = "com.rbx-mcp.server"
34 |
```
--------------------------------------------------------------------------------
/util/sign.macos.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 | set -ex
3 |
4 | BUNDLE_DIR=target/aarch64-apple-darwin/release/bundle
5 |
6 | if [ -n "$APPLE_API_KEY_CONTENT" ]
7 | then
8 | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
9 | function cleanup() {
10 | security delete-keychain "$KEYCHAIN_PATH" || true
11 | }
12 | trap cleanup EXIT
13 | KEYCHAIN_PASSWORD=$(head -1 /dev/random | md5)
14 |
15 | security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
16 | security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
17 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
18 |
19 | security import util/Certificates.p12 -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
20 | security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
21 | security list-keychain -d user -s "$KEYCHAIN_PATH"
22 |
23 | IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | cut -d' ' -f4 | tail -1)
24 | echo "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY"
25 |
26 |
27 | 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"
28 | ditto -c -k $BUNDLE_DIR/osx $BUNDLE_DIR/bund.zip
29 | xcrun notarytool submit -k "$APPLE_API_KEY" -d "$APPLE_API_KEY_ID" -i "$APPLE_API_ISSUER" --wait --progress $BUNDLE_DIR/bund.zip
30 | xcrun stapler staple "$BUNDLE_DIR/osx/RobloxStudioMCP.app"
31 | fi
32 |
33 | ditto -c -k $BUNDLE_DIR/osx output/macOS-rbx-studio-mcp.zip
34 |
```
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
```rust
1 | use axum::routing::{get, post};
2 | use clap::Parser;
3 | use color_eyre::eyre::Result;
4 | use rbx_studio_server::*;
5 | use rmcp::ServiceExt;
6 | use std::io;
7 | use std::net::Ipv4Addr;
8 | use std::sync::Arc;
9 | use tokio::sync::Mutex;
10 | use tracing_subscriber::{self, EnvFilter};
11 | mod error;
12 | mod install;
13 | mod rbx_studio_server;
14 |
15 | /// Simple MCP proxy for Roblox Studio
16 | /// Run without arguments to install the plugin
17 | #[derive(Parser)]
18 | #[command(version, about, long_about = None)]
19 | struct Args {
20 | /// Run as MCP server on stdio
21 | #[arg(short, long)]
22 | stdio: bool,
23 | }
24 |
25 | #[tokio::main]
26 | async fn main() -> Result<()> {
27 | color_eyre::install()?;
28 | tracing_subscriber::fmt()
29 | .with_env_filter(EnvFilter::from_default_env())
30 | .with_writer(io::stderr)
31 | .with_target(false)
32 | .with_thread_ids(true)
33 | .init();
34 |
35 | let args = Args::parse();
36 | if !args.stdio {
37 | return install::install().await;
38 | }
39 |
40 | tracing::debug!("Debug MCP tracing enabled");
41 |
42 | let server_state = Arc::new(Mutex::new(AppState::new()));
43 |
44 | let (close_tx, close_rx) = tokio::sync::oneshot::channel();
45 |
46 | let listener =
47 | tokio::net::TcpListener::bind((Ipv4Addr::new(127, 0, 0, 1), STUDIO_PLUGIN_PORT)).await;
48 |
49 | let server_state_clone = Arc::clone(&server_state);
50 | let server_handle = if let Ok(listener) = listener {
51 | let app = axum::Router::new()
52 | .route("/request", get(request_handler))
53 | .route("/response", post(response_handler))
54 | .route("/proxy", post(proxy_handler))
55 | .with_state(server_state_clone);
56 | tracing::info!("This MCP instance is HTTP server listening on {STUDIO_PLUGIN_PORT}");
57 | tokio::spawn(async {
58 | axum::serve(listener, app)
59 | .with_graceful_shutdown(async move {
60 | _ = close_rx.await;
61 | })
62 | .await
63 | .unwrap();
64 | })
65 | } else {
66 | tracing::info!("This MCP instance will use proxy since port is busy");
67 | tokio::spawn(async move {
68 | dud_proxy_loop(server_state_clone, close_rx).await;
69 | })
70 | };
71 |
72 | // Create an instance of our counter router
73 | let service = RBXStudioServer::new(Arc::clone(&server_state))
74 | .serve(rmcp::transport::stdio())
75 | .await
76 | .inspect_err(|e| {
77 | tracing::error!("serving error: {:?}", e);
78 | })?;
79 | service.waiting().await?;
80 |
81 | close_tx.send(()).ok();
82 | tracing::info!("Waiting for web server to gracefully shutdown");
83 | server_handle.await.ok();
84 | tracing::info!("Bye!");
85 | Ok(())
86 | }
87 |
```
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Build
2 |
3 | on:
4 | - push
5 | - pull_request
6 |
7 | env:
8 | CARGO_PKG_VERSION: "0.2.${{ github.run_number }}"
9 |
10 | permissions:
11 | contents: write
12 |
13 | jobs:
14 | build-macos:
15 | runs-on: macos-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Build
19 | run: |
20 | cargo install cargo-bundle cargo-edit
21 | cargo set-version --workspace "$CARGO_PKG_VERSION"
22 | rustup target add x86_64-apple-darwin aarch64-apple-darwin
23 | cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin
24 | cargo bundle --release --target aarch64-apple-darwin
25 | 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"
26 | - name: Sign and Notarize macOS binary
27 | run: ./util/sign.macos.sh
28 | env:
29 | APPLE_API_KEY: key.p8
30 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
31 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
32 | APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
33 | APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
34 | - name: Upload artifact
35 | uses: actions/upload-artifact@v4
36 | with:
37 | name: macOS-rbx-studio-mcp
38 | path: output/macOS-rbx-studio-mcp.zip
39 |
40 | build-windows:
41 | runs-on: windows-latest
42 | steps:
43 | - uses: actions/checkout@v4
44 | - name: Build
45 | run: |
46 | cargo install cargo-edit
47 | cargo set-version --workspace "$CARGO_PKG_VERSION"
48 | mkdir output
49 | cargo build --release
50 | - name: Sign windows binary
51 | run: ./util/sign.windows.ps1
52 | env:
53 | AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
54 | AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
55 | AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
56 | SIGNING_ACCOUNT: ${{ secrets.SIGNING_ACCOUNT }}
57 | - name: Upload artifact
58 | uses: actions/upload-artifact@v4
59 | with:
60 | name: Windows-rbx-studio-mcp
61 | path: output/rbx-studio-mcp.exe
62 |
63 | release:
64 | runs-on: ubuntu-latest
65 | if: ${{ github.ref_name == github.event.repository.default_branch }}
66 | needs: [build-macos, build-windows]
67 | steps:
68 | - run: mkdir -p output
69 | - uses: actions/download-artifact@v4
70 | with:
71 | path: output
72 | merge-multiple: true
73 | - name: Create release
74 | uses: Roblox-ActionsCache/softprops-action-gh-release@v1
75 | with:
76 | tag_name: v${{ env.CARGO_PKG_VERSION }}
77 | release_name: Release v${{ env.CARGO_PKG_VERSION }} }}
78 | files: output/*
79 |
```
--------------------------------------------------------------------------------
/src/install.rs:
--------------------------------------------------------------------------------
```rust
1 | use color_eyre::eyre::{eyre, Result, WrapErr};
2 | use color_eyre::Help;
3 | use roblox_install::RobloxStudio;
4 | use serde_json::{json, Value};
5 | use std::fs::File;
6 | use std::io::BufReader;
7 | use std::io::Write;
8 | use std::path::Path;
9 | use std::path::PathBuf;
10 | use std::vec;
11 | use std::{env, fs, io};
12 |
13 | fn get_message(successes: String) -> String {
14 | format!("Roblox Studio MCP is ready to go.
15 | Please restart Studio and MCP clients to apply the changes.
16 |
17 | MCP Clients set up:
18 | {successes}
19 |
20 | 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.
21 | To uninstall, delete the MCPStudioPlugin.rbxm from your Plugins directory.")
22 | }
23 |
24 | // returns OS dependant claude_desktop_config.json path
25 | fn get_claude_config() -> Result<PathBuf> {
26 | let home_dir = env::var_os("HOME");
27 |
28 | let config_path = if cfg!(target_os = "macos") {
29 | Path::new(&home_dir.unwrap())
30 | .join("Library/Application Support/Claude/claude_desktop_config.json")
31 | } else if cfg!(target_os = "windows") {
32 | let app_data =
33 | env::var_os("APPDATA").ok_or_else(|| eyre!("Could not find APPDATA directory"))?;
34 | Path::new(&app_data)
35 | .join("Claude")
36 | .join("claude_desktop_config.json")
37 | } else {
38 | return Err(eyre!("Unsupported operating system"));
39 | };
40 |
41 | Ok(config_path)
42 | }
43 |
44 | fn get_cursor_config() -> Result<PathBuf> {
45 | let home_dir = env::var_os("HOME")
46 | .or_else(|| env::var_os("USERPROFILE"))
47 | .unwrap();
48 | Ok(Path::new(&home_dir).join(".cursor").join("mcp.json"))
49 | }
50 |
51 | #[cfg(target_os = "macos")]
52 | fn get_exe_path() -> Result<PathBuf> {
53 | use core_foundation::url::CFURL;
54 |
55 | let local_path = env::current_exe()?;
56 | let local_path_cref = CFURL::from_path(local_path, false).unwrap();
57 | let un_relocated = security_translocate::create_original_path_for_url(local_path_cref.clone())
58 | .or_else(move |_| Ok::<CFURL, io::Error>(local_path_cref.clone()))?;
59 | let ret = un_relocated.to_path().unwrap();
60 | Ok(ret)
61 | }
62 |
63 | #[cfg(not(target_os = "macos"))]
64 | fn get_exe_path() -> io::Result<PathBuf> {
65 | env::current_exe()
66 | }
67 |
68 | pub fn install_to_config<'a>(
69 | config_path: Result<PathBuf>,
70 | exe_path: &Path,
71 | name: &'a str,
72 | ) -> Result<&'a str> {
73 | let config_path = config_path?;
74 | let mut config: serde_json::Map<String, Value> = {
75 | if !config_path.exists() {
76 | let mut file = File::create(&config_path).map_err(|e| {
77 | eyre!("Could not create {name} config file at {config_path:?}: {e:#?}")
78 | })?;
79 | file.write_all(serde_json::to_string(&serde_json::Map::new())?.as_bytes())?;
80 | }
81 | let config_file = File::open(&config_path)
82 | .map_err(|error| eyre!("Could not read or create {name} config file: {error:#?}"))?;
83 | let reader = BufReader::new(config_file);
84 | serde_json::from_reader(reader)?
85 | };
86 |
87 | if !matches!(config.get("mcpServers"), Some(Value::Object(_))) {
88 | config.insert("mcpServers".to_string(), json!({}));
89 | }
90 |
91 | config["mcpServers"]["Roblox Studio"] = json!({
92 | "command": &exe_path,
93 | "args": [
94 | "--stdio"
95 | ]
96 | });
97 |
98 | let mut file = File::create(&config_path)?;
99 | file.write_all(serde_json::to_string_pretty(&config)?.as_bytes())
100 | .map_err(|e| eyre!("Could not write to {name} config file at {config_path:?}: {e:#?}"))?;
101 |
102 | println!("Installed MCP Studio plugin to {name} config {config_path:?}");
103 |
104 | Ok(name)
105 | }
106 |
107 | async fn install_internal() -> Result<String> {
108 | let plugin_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/MCPStudioPlugin.rbxm"));
109 | let studio = RobloxStudio::locate()?;
110 | let plugins = studio.plugins_path();
111 | if let Err(err) = fs::create_dir(plugins) {
112 | if err.kind() != io::ErrorKind::AlreadyExists {
113 | return Err(err.into());
114 | }
115 | }
116 | let output_plugin = Path::new(&plugins).join("MCPStudioPlugin.rbxm");
117 | {
118 | let mut file = File::create(&output_plugin).wrap_err_with(|| {
119 | format!(
120 | "Could write Roblox Plugin file at {}",
121 | output_plugin.display()
122 | )
123 | })?;
124 | file.write_all(plugin_bytes)?;
125 | }
126 | println!(
127 | "Installed Roblox Studio plugin to {}",
128 | output_plugin.display()
129 | );
130 |
131 | let this_exe = get_exe_path()?;
132 |
133 | let mut errors = vec![];
134 | let results = vec![
135 | install_to_config(get_claude_config(), &this_exe, "Claude"),
136 | install_to_config(get_cursor_config(), &this_exe, "Cursor"),
137 | ];
138 |
139 | let successes: Vec<_> = results
140 | .into_iter()
141 | .filter_map(|r| r.map_err(|e| errors.push(e)).ok())
142 | .collect();
143 |
144 | if successes.is_empty() {
145 | let error = errors.into_iter().fold(
146 | eyre!("Failed to install to either Claude or Cursor"),
147 | |report, e| report.note(e),
148 | );
149 | return Err(error);
150 | }
151 |
152 | println!();
153 | let msg = get_message(successes.join("\n"));
154 | println!("{msg}");
155 | Ok(msg)
156 | }
157 |
158 | #[cfg(target_os = "windows")]
159 | pub async fn install() -> Result<()> {
160 | use std::process::Command;
161 | if let Err(e) = install_internal().await {
162 | tracing::error!("Failed initialize Roblox MCP: {:#}", e);
163 | }
164 | let _ = Command::new("cmd.exe").arg("/c").arg("pause").status();
165 | Ok(())
166 | }
167 |
168 | #[cfg(target_os = "macos")]
169 | pub async fn install() -> Result<()> {
170 | use native_dialog::{DialogBuilder, MessageLevel};
171 | let alert_builder = match install_internal().await {
172 | Err(e) => DialogBuilder::message()
173 | .set_level(MessageLevel::Error)
174 | .set_text(format!("Errors occurred: {e:#}")),
175 | Ok(msg) => DialogBuilder::message()
176 | .set_level(MessageLevel::Info)
177 | .set_text(msg),
178 | };
179 | let _ = alert_builder.set_title("Roblox Studio MCP").alert().show();
180 | Ok(())
181 | }
182 |
183 | #[cfg(not(any(target_os = "macos", target_os = "windows")))]
184 | pub async fn install() -> Result<()> {
185 | install_internal().await?;
186 | Ok(())
187 | }
188 |
```
--------------------------------------------------------------------------------
/src/rbx_studio_server.rs:
--------------------------------------------------------------------------------
```rust
1 | use crate::error::Result;
2 | use axum::http::StatusCode;
3 | use axum::response::IntoResponse;
4 | use axum::{extract::State, Json};
5 | use color_eyre::eyre::{Error, OptionExt};
6 | use rmcp::{
7 | handler::server::tool::Parameters,
8 | model::{
9 | CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
10 | },
11 | schemars, tool, tool_handler, tool_router, ErrorData, ServerHandler,
12 | };
13 | use serde::{Deserialize, Serialize};
14 | use std::collections::{HashMap, VecDeque};
15 | use std::future::Future;
16 | use std::sync::Arc;
17 | use tokio::sync::oneshot::Receiver;
18 | use tokio::sync::{mpsc, watch, Mutex};
19 | use tokio::time::Duration;
20 | use uuid::Uuid;
21 |
22 | pub const STUDIO_PLUGIN_PORT: u16 = 44755;
23 | const LONG_POLL_DURATION: Duration = Duration::from_secs(15);
24 |
25 | #[derive(Deserialize, Serialize, Clone, Debug)]
26 | pub struct ToolArguments {
27 | args: ToolArgumentValues,
28 | id: Option<Uuid>,
29 | }
30 |
31 | #[derive(Deserialize, Serialize, Clone, Debug)]
32 | pub struct RunCommandResponse {
33 | response: String,
34 | id: Uuid,
35 | }
36 |
37 | pub struct AppState {
38 | process_queue: VecDeque<ToolArguments>,
39 | output_map: HashMap<Uuid, mpsc::UnboundedSender<Result<String>>>,
40 | waiter: watch::Receiver<()>,
41 | trigger: watch::Sender<()>,
42 | }
43 | pub type PackedState = Arc<Mutex<AppState>>;
44 |
45 | impl AppState {
46 | pub fn new() -> Self {
47 | let (trigger, waiter) = watch::channel(());
48 | Self {
49 | process_queue: VecDeque::new(),
50 | output_map: HashMap::new(),
51 | waiter,
52 | trigger,
53 | }
54 | }
55 | }
56 |
57 | impl ToolArguments {
58 | fn new(args: ToolArgumentValues) -> (Self, Uuid) {
59 | Self { args, id: None }.with_id()
60 | }
61 | fn with_id(self) -> (Self, Uuid) {
62 | let id = Uuid::new_v4();
63 | (
64 | Self {
65 | args: self.args,
66 | id: Some(id),
67 | },
68 | id,
69 | )
70 | }
71 | }
72 | #[derive(Clone)]
73 | pub struct RBXStudioServer {
74 | state: PackedState,
75 | tool_router: rmcp::handler::server::tool::ToolRouter<Self>,
76 | }
77 |
78 | #[tool_handler]
79 | impl ServerHandler for RBXStudioServer {
80 | fn get_info(&self) -> ServerInfo {
81 | ServerInfo {
82 | protocol_version: ProtocolVersion::V_2025_03_26,
83 | capabilities: ServerCapabilities::builder().enable_tools().build(),
84 | server_info: Implementation::from_build_env(),
85 | instructions: Some(
86 | "User run_command to query data from Roblox Studio place or to change it"
87 | .to_string(),
88 | ),
89 | }
90 | }
91 | }
92 |
93 | #[derive(Debug, Deserialize, Serialize, schemars::JsonSchema, Clone)]
94 | struct RunCode {
95 | #[schemars(description = "Code to run")]
96 | command: String,
97 | }
98 | #[derive(Debug, Deserialize, Serialize, schemars::JsonSchema, Clone)]
99 | struct InsertModel {
100 | #[schemars(description = "Query to search for the model")]
101 | query: String,
102 | }
103 |
104 | #[derive(Debug, Deserialize, Serialize, schemars::JsonSchema, Clone)]
105 | enum ToolArgumentValues {
106 | RunCode(RunCode),
107 | InsertModel(InsertModel),
108 | }
109 | #[tool_router]
110 | impl RBXStudioServer {
111 | pub fn new(state: PackedState) -> Self {
112 | Self {
113 | state,
114 | tool_router: Self::tool_router(),
115 | }
116 | }
117 |
118 | #[tool(
119 | description = "Runs a command in Roblox Studio and returns the printed output. Can be used to both make changes and retrieve information"
120 | )]
121 | async fn run_code(
122 | &self,
123 | Parameters(args): Parameters<RunCode>,
124 | ) -> Result<CallToolResult, ErrorData> {
125 | self.generic_tool_run(ToolArgumentValues::RunCode(args))
126 | .await
127 | }
128 |
129 | #[tool(
130 | description = "Inserts a model from the Roblox marketplace into the workspace. Returns the inserted model name."
131 | )]
132 | async fn insert_model(
133 | &self,
134 | Parameters(args): Parameters<InsertModel>,
135 | ) -> Result<CallToolResult, ErrorData> {
136 | self.generic_tool_run(ToolArgumentValues::InsertModel(args))
137 | .await
138 | }
139 |
140 | async fn generic_tool_run(
141 | &self,
142 | args: ToolArgumentValues,
143 | ) -> Result<CallToolResult, ErrorData> {
144 | let (command, id) = ToolArguments::new(args);
145 | tracing::debug!("Running command: {:?}", command);
146 | let (tx, mut rx) = mpsc::unbounded_channel::<Result<String>>();
147 | let trigger = {
148 | let mut state = self.state.lock().await;
149 | state.process_queue.push_back(command);
150 | state.output_map.insert(id, tx);
151 | state.trigger.clone()
152 | };
153 | trigger
154 | .send(())
155 | .map_err(|e| ErrorData::internal_error(format!("Unable to trigger send {e}"), None))?;
156 | let result = rx
157 | .recv()
158 | .await
159 | .ok_or(ErrorData::internal_error("Couldn't receive response", None))?;
160 | {
161 | let mut state = self.state.lock().await;
162 | state.output_map.remove_entry(&id);
163 | }
164 | tracing::debug!("Sending to MCP: {result:?}");
165 | match result {
166 | Ok(result) => Ok(CallToolResult::success(vec![Content::text(result)])),
167 | Err(err) => Ok(CallToolResult::error(vec![Content::text(err.to_string())])),
168 | }
169 | }
170 | }
171 |
172 | pub async fn request_handler(State(state): State<PackedState>) -> Result<impl IntoResponse> {
173 | let timeout = tokio::time::timeout(LONG_POLL_DURATION, async {
174 | loop {
175 | let mut waiter = {
176 | let mut state = state.lock().await;
177 | if let Some(task) = state.process_queue.pop_front() {
178 | return Ok::<ToolArguments, Error>(task);
179 | }
180 | state.waiter.clone()
181 | };
182 | waiter.changed().await?
183 | }
184 | })
185 | .await;
186 | match timeout {
187 | Ok(result) => Ok(Json(result?).into_response()),
188 | _ => Ok((StatusCode::LOCKED, String::new()).into_response()),
189 | }
190 | }
191 |
192 | pub async fn response_handler(
193 | State(state): State<PackedState>,
194 | Json(payload): Json<RunCommandResponse>,
195 | ) -> Result<impl IntoResponse> {
196 | tracing::debug!("Received reply from studio {payload:?}");
197 | let mut state = state.lock().await;
198 | let tx = state
199 | .output_map
200 | .remove(&payload.id)
201 | .ok_or_eyre("Unknown ID")?;
202 | Ok(tx.send(Ok(payload.response))?)
203 | }
204 |
205 | pub async fn proxy_handler(
206 | State(state): State<PackedState>,
207 | Json(command): Json<ToolArguments>,
208 | ) -> Result<impl IntoResponse> {
209 | let id = command.id.ok_or_eyre("Got proxy command with no id")?;
210 | tracing::debug!("Received request to proxy {command:?}");
211 | let (tx, mut rx) = mpsc::unbounded_channel();
212 | {
213 | let mut state = state.lock().await;
214 | state.process_queue.push_back(command);
215 | state.output_map.insert(id, tx);
216 | }
217 | let response = rx.recv().await.ok_or_eyre("Couldn't receive response")??;
218 | {
219 | let mut state = state.lock().await;
220 | state.output_map.remove_entry(&id);
221 | }
222 | tracing::debug!("Sending back to dud: {response:?}");
223 | Ok(Json(RunCommandResponse { response, id }))
224 | }
225 |
226 | pub async fn dud_proxy_loop(state: PackedState, exit: Receiver<()>) {
227 | let client = reqwest::Client::new();
228 |
229 | let mut waiter = { state.lock().await.waiter.clone() };
230 | while exit.is_empty() {
231 | let entry = { state.lock().await.process_queue.pop_front() };
232 | if let Some(entry) = entry {
233 | let res = client
234 | .post(format!("http://127.0.0.1:{STUDIO_PLUGIN_PORT}/proxy"))
235 | .json(&entry)
236 | .send()
237 | .await;
238 | if let Ok(res) = res {
239 | let tx = {
240 | state
241 | .lock()
242 | .await
243 | .output_map
244 | .remove(&entry.id.unwrap())
245 | .unwrap()
246 | };
247 | let res = res
248 | .json::<RunCommandResponse>()
249 | .await
250 | .map(|r| r.response)
251 | .map_err(Into::into);
252 | tx.send(res).unwrap();
253 | } else {
254 | tracing::error!("Failed to proxy: {res:?}");
255 | };
256 | } else {
257 | waiter.changed().await.unwrap();
258 | }
259 | }
260 | }
261 |
```