mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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:
parent
fc8a6e9b9c
commit
c5502191d3
7 changed files with 248 additions and 24 deletions
126
src/web/api.rs
126
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<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 {
|
||||
serve_session: Arc<ServeSession>,
|
||||
suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,
|
||||
|
|
|
|||
|
|
@ -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<Value>) -> Response<Full<Bytes>> {
|
|||
"run_script_in_play_mode",
|
||||
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 });
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
12
src/web/mcp_docs/syncback.md
Normal file
12
src/web/mcp_docs/syncback.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue