diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 5f93f361..43bb6df1 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -215,6 +215,43 @@ function App:init() onGetScriptCommand = function(requestId, data) return self:handleMcpGetScript(requestId, data) end, + onSyncbackCommand = function(requestId) + return Promise.new(function(resolve) + local host = Config.defaultHost + local port = Config.defaultPort + + self:collectAndPostSyncback(host, port, "/api/mcp/syncback") + :andThen(function(httpResponse) + local message = "Syncback complete." + local decodeOk, decoded = pcall(function() + return httpResponse:msgpack() + end) + if decodeOk and type(decoded) == "table" then + local added = decoded.added or 0 + local removed = decoded.removed or 0 + message = string.format( + "Syncback complete: wrote %d files/folders, removed %d.", + added, + removed + ) + end + Log.info("MCP syncback complete") + resolve({ + requestId = requestId, + status = "success", + response = message, + }) + end) + :catch(function(err) + Log.warn("MCP syncback failed: {}", tostring(err)) + resolve({ + requestId = requestId, + status = "error", + response = "Syncback failed: " .. tostring(err), + }) + end) + end) + end, onToolCommand = function(requestId, toolType, args) return Promise.new(function(resolve) local ok, result = pcall(McpTools.dispatch, toolType, args) @@ -680,19 +717,7 @@ function App:useRunningConnectionInfo() self.setPort(port) end -function App:performSyncback() - self:setState({ showingSyncbackConfirm = false }) - - local host = self.host:getValue() - local port = self.port:getValue() - - if host == "" then - host = Config.defaultHost - end - if port == "" then - port = Config.defaultPort - end - +function App:collectAndPostSyncback(host, port, endpoint) local services = {} local allChildren = {} local allCarriers = {} @@ -720,7 +745,7 @@ function App:performSyncback() carrier:Destroy() end - local url = ("http://%s:%s/api/syncback"):format(host, port) + local url = ("http://%s:%s%s"):format(host, port, endpoint) local body = Http.msgpackEncode({ protocolVersion = Config.protocolVersion, serverVersion = Config.expectedServerVersionString, @@ -729,7 +754,23 @@ function App:performSyncback() services = services, }) - Http.post(url, body) + return Http.post(url, body) +end + +function App:performSyncback() + self:setState({ showingSyncbackConfirm = false }) + + local host = self.host:getValue() + local port = self.port:getValue() + + if host == "" then + host = Config.defaultHost + end + if port == "" then + port = Config.defaultPort + end + + self:collectAndPostSyncback(host, port, "/api/syncback") :andThen(function() Log.info("Syncback data sent to server.") self:addNotification({ diff --git a/plugin/src/McpStream.lua b/plugin/src/McpStream.lua index c3190d71..d4166ab3 100644 --- a/plugin/src/McpStream.lua +++ b/plugin/src/McpStream.lua @@ -28,6 +28,7 @@ function McpStream.new(options) self._onSyncCommand = options.onSyncCommand self._onGetScriptCommand = options.onGetScriptCommand + self._onSyncbackCommand = options.onSyncbackCommand self._onToolCommand = options.onToolCommand self._getPluginConfig = options.getPluginConfig self._wsClient = nil @@ -178,6 +179,33 @@ function McpStream:_tryConnect() end) end end) + elseif data.type == "syncback" and data.requestId then + Log.info("MCP stream: received syncback command (requestId={})", data.requestId) + + local resultPromise = self._onSyncbackCommand(data.requestId) + + resultPromise + :andThen(function(result) + if self._wsClient then + local json = HttpService:JSONEncode(result) + pcall(function() + self._wsClient:Send(json) + end) + end + end) + :catch(function(err) + Log.warn("MCP stream: syncback command failed: {}", tostring(err)) + if self._wsClient then + local errorResult = HttpService:JSONEncode({ + requestId = data.requestId, + status = "error", + response = tostring(err), + }) + pcall(function() + self._wsClient:Send(errorResult) + end) + end + end) elseif data.type and data.requestId and PASSTHROUGH_TOOLS[data.type] then Log.info("MCP stream: received {} command (requestId={})", data.type, data.requestId) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 69c28e52..950ab829 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -8,7 +8,7 @@ mod doc; mod fmt_project; mod init; mod plugin; -mod serve; +pub(crate) mod serve; mod sourcemap; mod studio; mod syncback; diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 8c9e241c..eb4933c9 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -77,7 +77,7 @@ impl ServeCommand { ServerExitReason::SyncbackRequested(payload) => { log::info!("Live syncback requested, running..."); match run_live_syncback(&project_path, payload) { - Ok(()) => log::info!("Syncback complete, restarting serve..."), + Ok(_stats) => log::info!("Syncback complete, restarting serve..."), Err(err) => { log::error!("Live syncback failed: {err:#}. Restarting serve...") } @@ -95,7 +95,15 @@ impl ServeCommand { } } -fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::Result<()> { +pub(crate) struct SyncbackStats { + pub added: usize, + pub removed: usize, +} + +pub(crate) fn run_live_syncback( + project_path: &Path, + payload: SyncbackPayload, +) -> anyhow::Result { let new_dom = build_dom_from_chunks(payload)?; let vfs = Vfs::new_oneshot(); @@ -128,20 +136,23 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R .fs_snapshot .write_to_vfs_parallel(base_path, session_old.vfs(), git_cache.as_ref())?; + let added = result.fs_snapshot.added_paths().len(); + let removed = result.fs_snapshot.removed_paths().len(); + log::info!( "Finished live syncback: wrote {} files/folders, removed {}.", - result.fs_snapshot.added_paths().len(), - result.fs_snapshot.removed_paths().len() + added, + removed ); crate::git::refresh_git_index(base_path); drop(session_old); - Ok(()) + Ok(SyncbackStats { added, removed }) } -fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result { +pub(crate) fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result { use crate::syncback::VISIBLE_SERVICES; let mut dom = WeakDom::new(InstanceBuilder::new("DataModel")); diff --git a/src/web/api.rs b/src/web/api.rs index ba539bda..998adbfe 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -182,6 +182,7 @@ pub async fn call( (&Method::POST, "/api/syncback") => { handle_api_syncback(request, &service, syncback_signal).await } + (&Method::POST, "/api/mcp/syncback") => handle_mcp_syncback(request, &service).await, (&Method::GET, "/api/validate-tree") => service.handle_api_validate_tree().await, (&Method::GET, "/api/git-metadata") => service.handle_api_git_metadata().await, @@ -291,6 +292,131 @@ async fn handle_api_syncback( msgpack_ok(serde_json::json!({"status": "syncback_initiated"})) } +pub(super) async fn handle_mcp_syncback( + request: Request, + service: &ApiService, +) -> Response> { + let body = match request.into_body().collect().await { + Ok(collected) => collected.to_bytes(), + Err(err) => { + return msgpack( + ErrorResponse::bad_request(format!("Failed to read request body: {err}")), + StatusCode::BAD_REQUEST, + ); + } + }; + + let syncback_request: SyncbackRequest = match deserialize_msgpack(&body) { + Ok(req) => req, + Err(err) => { + return msgpack( + ErrorResponse::bad_request(format!( + "Failed to deserialize syncback request: {err}" + )), + StatusCode::BAD_REQUEST, + ); + } + }; + + let client_protocol = syncback_request.protocol_version as u64; + if client_protocol != PROTOCOL_VERSION { + return msgpack( + ErrorResponse::bad_request(format!( + "Protocol version mismatch: expected {}, got {}", + PROTOCOL_VERSION, client_protocol + )), + StatusCode::BAD_REQUEST, + ); + } + + let server_semver = SERVER_VERSION; + let server_parts: Vec<&str> = server_semver.split('.').collect(); + let client_parts: Vec<&str> = syncback_request.server_version.split('.').collect(); + let server_major_minor = server_parts.get(..2); + let client_major_minor = client_parts.get(..2); + if server_major_minor != client_major_minor { + return msgpack( + ErrorResponse::bad_request(format!( + "Server version mismatch: server is {}, plugin expects {}", + SERVER_VERSION, syncback_request.server_version + )), + StatusCode::BAD_REQUEST, + ); + } + + if let Some(place_id_f64) = syncback_request.place_id { + let place_id = place_id_f64 as u64; + if let Some(expected) = service.serve_session.serve_place_ids() { + if !expected.contains(&place_id) { + return msgpack( + ErrorResponse::bad_request(format!( + "Place ID {} is not in the servePlaceIds whitelist", + place_id + )), + StatusCode::FORBIDDEN, + ); + } + } + if let Some(blocked) = service.serve_session.blocked_place_ids() { + if blocked.contains(&place_id) { + return msgpack( + ErrorResponse::bad_request(format!( + "Place ID {} is in the blockedPlaceIds list", + place_id + )), + StatusCode::FORBIDDEN, + ); + } + } + } + + log::info!( + "MCP syncback requested with {} service chunks, {} bytes of rbxm data", + syncback_request.services.len(), + syncback_request.data.len() + ); + + let payload = SyncbackPayload { + data: syncback_request.data, + services: syncback_request.services, + }; + + let project_path = service.serve_session.root_project().file_location.clone(); + + match tokio::task::spawn_blocking(move || { + crate::cli::serve::run_live_syncback(&project_path, payload) + }) + .await + { + Ok(Ok(stats)) => { + log::info!( + "MCP syncback complete: wrote {} files/folders, removed {}", + stats.added, + stats.removed + ); + msgpack_ok(serde_json::json!({ + "status": "syncback_complete", + "added": stats.added, + "removed": stats.removed, + })) + } + Ok(Err(err)) => { + log::error!("MCP syncback failed: {err:#}"); + msgpack( + ErrorResponse::bad_request(format!("Syncback failed: {err:#}")), + StatusCode::INTERNAL_SERVER_ERROR, + ) + } + Err(err) => { + log::error!("MCP syncback task panicked: {err}"); + msgpack( + ErrorResponse::bad_request("Syncback task panicked"), + StatusCode::INTERNAL_SERVER_ERROR, + ) + } + } +} + pub struct ApiService { serve_session: Arc, suppressed_paths: Arc>>, diff --git a/src/web/mcp.rs b/src/web/mcp.rs index a519e6f4..10106396 100644 --- a/src/web/mcp.rs +++ b/src/web/mcp.rs @@ -155,6 +155,9 @@ struct InsertModelArgs { #[derive(JsonSchema)] struct NoArgs {} +#[derive(JsonSchema)] +struct SyncbackArgs {} + #[allow(dead_code)] #[derive(JsonSchema)] #[schemars(rename_all = "snake_case")] @@ -431,6 +434,7 @@ fn handle_tools_list(id: Option) -> Response> { "run_script_in_play_mode", include_str!("mcp_docs/run_script_in_play_mode.md"), ), + tool_def::("syncback", include_str!("mcp_docs/syncback.md")), ]; let result = serde_json::json!({ "tools": tools }); @@ -465,6 +469,7 @@ async fn handle_tools_call( match tool_name { "atlas_sync" => handle_atlas_sync(id, arguments, mcp_state, active_api_connections).await, "get_script" => handle_get_script(id, arguments, mcp_state).await, + "syncback" => dispatch_to_plugin(id, "syncback", arguments, mcp_state).await, "run_code" | "insert_model" | "get_console_output" @@ -1136,7 +1141,7 @@ mod tests { let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() }); let json: Value = serde_json::from_slice(&bytes).unwrap(); let tools = json["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 8); + assert_eq!(tools.len(), 9); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert_eq!( names, @@ -1149,6 +1154,7 @@ mod tests { "get_studio_mode", "start_stop_play", "run_script_in_play_mode", + "syncback", ] ); for tool in tools { @@ -1874,7 +1880,7 @@ mod tests { let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() }); let json: Value = serde_json::from_slice(&bytes).unwrap(); let tools = json["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 8); + assert_eq!(tools.len(), 9); let get_script = tools.iter().find(|t| t["name"] == "get_script").unwrap(); assert!(get_script["description"] diff --git a/src/web/mcp_docs/syncback.md b/src/web/mcp_docs/syncback.md new file mode 100644 index 00000000..9f43ef09 --- /dev/null +++ b/src/web/mcp_docs/syncback.md @@ -0,0 +1,12 @@ +Pull the entire Roblox Studio place to the filesystem (Studio to Atlas). + +This performs a full "clean mode" syncback: the plugin serializes every service and its children, sends the data to the server, and the server writes the complete filesystem tree. Unlike `atlas_sync` (which syncs individual changes bidirectionally), this replaces the entire project on disk with the current Studio state. + +Use this when: +- You want to capture the full Studio state to disk (e.g., after building a level in Studio). +- The filesystem is out of date and you want to overwrite it with what's in Studio. +- You want a one-shot pull of everything rather than incremental sync. + +No arguments are required. The plugin must be connected to the MCP stream. + +Response includes the number of files written and removed.