Add syncback command handling to MCP stream and API

- Introduced a new `onSyncbackCommand` option in `McpStream` to handle syncback requests.
- Implemented the `handle_mcp_syncback` function in the API to process syncback requests, including validation and logging.
- Updated the `run_live_syncback` function to return statistics on added and removed files during the syncback process.
- Enhanced the MCP tools list to include the new syncback tool, with corresponding documentation added.

These changes aim to facilitate a complete syncback of the Roblox Studio state to the filesystem, improving project management capabilities.
This commit is contained in:
Joe 2026-03-01 18:49:34 -06:00
parent fc8a6e9b9c
commit c5502191d3
7 changed files with 248 additions and 24 deletions

View file

@ -215,6 +215,43 @@ function App:init()
onGetScriptCommand = function(requestId, data) onGetScriptCommand = function(requestId, data)
return self:handleMcpGetScript(requestId, data) return self:handleMcpGetScript(requestId, data)
end, 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) onToolCommand = function(requestId, toolType, args)
return Promise.new(function(resolve) return Promise.new(function(resolve)
local ok, result = pcall(McpTools.dispatch, toolType, args) local ok, result = pcall(McpTools.dispatch, toolType, args)
@ -680,19 +717,7 @@ function App:useRunningConnectionInfo()
self.setPort(port) self.setPort(port)
end end
function App:performSyncback() function App:collectAndPostSyncback(host, port, endpoint)
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
local services = {} local services = {}
local allChildren = {} local allChildren = {}
local allCarriers = {} local allCarriers = {}
@ -720,7 +745,7 @@ function App:performSyncback()
carrier:Destroy() carrier:Destroy()
end 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({ local body = Http.msgpackEncode({
protocolVersion = Config.protocolVersion, protocolVersion = Config.protocolVersion,
serverVersion = Config.expectedServerVersionString, serverVersion = Config.expectedServerVersionString,
@ -729,7 +754,23 @@ function App:performSyncback()
services = services, 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() :andThen(function()
Log.info("Syncback data sent to server.") Log.info("Syncback data sent to server.")
self:addNotification({ self:addNotification({

View file

@ -28,6 +28,7 @@ function McpStream.new(options)
self._onSyncCommand = options.onSyncCommand self._onSyncCommand = options.onSyncCommand
self._onGetScriptCommand = options.onGetScriptCommand self._onGetScriptCommand = options.onGetScriptCommand
self._onSyncbackCommand = options.onSyncbackCommand
self._onToolCommand = options.onToolCommand self._onToolCommand = options.onToolCommand
self._getPluginConfig = options.getPluginConfig self._getPluginConfig = options.getPluginConfig
self._wsClient = nil self._wsClient = nil
@ -178,6 +179,33 @@ function McpStream:_tryConnect()
end) end)
end 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 elseif data.type and data.requestId and PASSTHROUGH_TOOLS[data.type] then
Log.info("MCP stream: received {} command (requestId={})", data.type, data.requestId) Log.info("MCP stream: received {} command (requestId={})", data.type, data.requestId)

View file

@ -8,7 +8,7 @@ mod doc;
mod fmt_project; mod fmt_project;
mod init; mod init;
mod plugin; mod plugin;
mod serve; pub(crate) mod serve;
mod sourcemap; mod sourcemap;
mod studio; mod studio;
mod syncback; mod syncback;

View file

@ -77,7 +77,7 @@ impl ServeCommand {
ServerExitReason::SyncbackRequested(payload) => { ServerExitReason::SyncbackRequested(payload) => {
log::info!("Live syncback requested, running..."); log::info!("Live syncback requested, running...");
match run_live_syncback(&project_path, payload) { match run_live_syncback(&project_path, payload) {
Ok(()) => log::info!("Syncback complete, restarting serve..."), Ok(_stats) => log::info!("Syncback complete, restarting serve..."),
Err(err) => { Err(err) => {
log::error!("Live syncback failed: {err:#}. Restarting serve...") 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<SyncbackStats> {
let new_dom = build_dom_from_chunks(payload)?; let new_dom = build_dom_from_chunks(payload)?;
let vfs = Vfs::new_oneshot(); let vfs = Vfs::new_oneshot();
@ -128,20 +136,23 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R
.fs_snapshot .fs_snapshot
.write_to_vfs_parallel(base_path, session_old.vfs(), git_cache.as_ref())?; .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!( log::info!(
"Finished live syncback: wrote {} files/folders, removed {}.", "Finished live syncback: wrote {} files/folders, removed {}.",
result.fs_snapshot.added_paths().len(), added,
result.fs_snapshot.removed_paths().len() removed
); );
crate::git::refresh_git_index(base_path); crate::git::refresh_git_index(base_path);
drop(session_old); drop(session_old);
Ok(()) Ok(SyncbackStats { added, removed })
} }
fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> { pub(crate) fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> {
use crate::syncback::VISIBLE_SERVICES; use crate::syncback::VISIBLE_SERVICES;
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel")); let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));

View file

@ -182,6 +182,7 @@ pub async fn call(
(&Method::POST, "/api/syncback") => { (&Method::POST, "/api/syncback") => {
handle_api_syncback(request, &service, syncback_signal).await 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/validate-tree") => service.handle_api_validate_tree().await,
(&Method::GET, "/api/git-metadata") => service.handle_api_git_metadata().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"})) msgpack_ok(serde_json::json!({"status": "syncback_initiated"}))
} }
pub(super) async fn handle_mcp_syncback(
request: Request<Incoming>,
service: &ApiService,
) -> Response<Full<Bytes>> {
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 { pub struct ApiService {
serve_session: Arc<ServeSession>, serve_session: Arc<ServeSession>,
suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>, suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,

View file

@ -155,6 +155,9 @@ struct InsertModelArgs {
#[derive(JsonSchema)] #[derive(JsonSchema)]
struct NoArgs {} struct NoArgs {}
#[derive(JsonSchema)]
struct SyncbackArgs {}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(JsonSchema)] #[derive(JsonSchema)]
#[schemars(rename_all = "snake_case")] #[schemars(rename_all = "snake_case")]
@ -431,6 +434,7 @@ fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
"run_script_in_play_mode", "run_script_in_play_mode",
include_str!("mcp_docs/run_script_in_play_mode.md"), include_str!("mcp_docs/run_script_in_play_mode.md"),
), ),
tool_def::<SyncbackArgs>("syncback", include_str!("mcp_docs/syncback.md")),
]; ];
let result = serde_json::json!({ "tools": tools }); let result = serde_json::json!({ "tools": tools });
@ -465,6 +469,7 @@ async fn handle_tools_call(
match tool_name { match tool_name {
"atlas_sync" => handle_atlas_sync(id, arguments, mcp_state, active_api_connections).await, "atlas_sync" => handle_atlas_sync(id, arguments, mcp_state, active_api_connections).await,
"get_script" => handle_get_script(id, arguments, mcp_state).await, "get_script" => handle_get_script(id, arguments, mcp_state).await,
"syncback" => dispatch_to_plugin(id, "syncback", arguments, mcp_state).await,
"run_code" "run_code"
| "insert_model" | "insert_model"
| "get_console_output" | "get_console_output"
@ -1136,7 +1141,7 @@ mod tests {
let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() }); let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() });
let json: Value = serde_json::from_slice(&bytes).unwrap(); let json: Value = serde_json::from_slice(&bytes).unwrap();
let tools = json["result"]["tools"].as_array().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(); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!( assert_eq!(
names, names,
@ -1149,6 +1154,7 @@ mod tests {
"get_studio_mode", "get_studio_mode",
"start_stop_play", "start_stop_play",
"run_script_in_play_mode", "run_script_in_play_mode",
"syncback",
] ]
); );
for tool in tools { 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 bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() });
let json: Value = serde_json::from_slice(&bytes).unwrap(); let json: Value = serde_json::from_slice(&bytes).unwrap();
let tools = json["result"]["tools"].as_array().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(); let get_script = tools.iter().find(|t| t["name"] == "get_script").unwrap();
assert!(get_script["description"] assert!(get_script["description"]

View file

@ -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.