#
tokens: 10961/50000 23/23 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | ![Scheme](MCP-Server.png)
 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 | 
```