diff --git a/TODO.txt b/TODO.txt index 739c5aa2..f133e80a 100644 --- a/TODO.txt +++ b/TODO.txt @@ -316,6 +316,7 @@ we can utilize the git hashes we also use for auto select, too. we use the same because have already loaded it, we can detect this. this is an optimization to avoid writing 100,000 files or something on larger projects when file bytes match perfectly, or directories verifiably already exist. make sure this is applied to plugin-based syncback, too. +can we do hash based comparisons, too? it seems most effective for actual file changes.. but im not sure.. [fix dedup] dedup is broken due to relying on sort order @@ -323,26 +324,8 @@ recreate broken state by forcing random order in all matching dedup systems of t if it cannot match correctly with randomization, then it is inherently broken in all 3 matchers, simply randomize the order of the children - this will break the tests. -[mcp] -* atlas_sync - - Basically equiv to git fast-forward merge - - Atlas plugin automatically connects (tries every 5s if nothing there, do not spam errors) via https://create.roblox.com/docs/reference/engine/classes/HttpService#CreateWebStreamClient - - Atlas serve automatically hosts a http based mcp server (NOT stdio based!) - - Decide whether it's easier to make a separate server from the api.rs file, or integrate it - integrating would be cleaner, but im not sure if it would support without a massive amount of changes - - When an agent invokes atlas_sync, it invokes Atlas sync from Roblox in the plugin as if the user clicked the sync button - - Must be on a mutex, so multiple agents cannot invoke it at the same time. Just refuses if it's currently happening - - Also it needs to refuse it if we're actively connected on the regular api layer too, not just the mcp based command. - - If we are connected already, respond to the agent with a proper notice. Make sure if we are in connected-mode where all things are syncing automatically all the time, we tell the agent. We need to tell the agent the configuration of the lua plugin probably? - - If we are in connected-mode we will need to figure out a way to await syncing to finish to ensure state is good? - - Upon the plugin connecting, if there are NO changes (Always/Initial) it should just immediately accept - - If all changes are pre-selected, e.g. from our git pre selection system, then we "fast-forward" by clicking accept immediately. - - If there are actually changes, the agent should hang while waiting for the user to either accept/deny them - - The output of the mcp request needs to conclude that it was a success and what changes were synced AND if it was rejected + what changes were presented to the user to sync. - - Let's just give a list of file paths on the actual disk (relative to project so like ReplicatedStorage/SomeFile.luau or ReplicatedStorage/SomeModel.json5 etc) ? - - Needs to include the DIRECTION, too. - - I'm not sure what is the most value on this, but we absolutely need to give the agent a response on what was actually synced, ideally what the content was(?), and the directions of sync. - - Does nothing if we are running in connected-mode (already syncing) - - Agent should await the completion of this sync before proceeding, this mcp command needs to yield and give the response as described above. +[syncback via extension] +can we allow the terminal to stay so i can like run some git commands myself without having to make a whole new terminal? [mcp] * syncback diff --git a/plugin/src/McpStream.lua b/plugin/src/McpStream.lua index da3e2805..614c7cc3 100644 --- a/plugin/src/McpStream.lua +++ b/plugin/src/McpStream.lua @@ -78,6 +78,7 @@ function McpStream:_tryConnect() twoWaySync = self._getPluginConfig("twoWaySync"), oneShotSync = self._getPluginConfig("oneShotSync"), confirmationBehavior = self._getPluginConfig("confirmationBehavior"), + placeId = game.PlaceId, }) pcall(function() wsClient:Send(greeting) diff --git a/src/web/api.rs b/src/web/api.rs index 22583ff3..3c3d7f6b 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -137,7 +137,13 @@ pub async fn call( } (&Method::GET, "/api/mcp/stream") => { if is_upgrade_request(&request) { - handle_mcp_stream_upgrade(&mut request, mcp_state, active_api_connections).await + handle_mcp_stream_upgrade( + &mut request, + Arc::clone(&service.serve_session), + mcp_state, + active_api_connections, + ) + .await } else { msgpack( ErrorResponse::bad_request( @@ -3518,6 +3524,7 @@ fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option { async fn handle_mcp_stream_upgrade( request: &mut Request, + serve_session: Arc, mcp_state: Arc, active_api_connections: Arc, ) -> Response> { @@ -3541,8 +3548,13 @@ async fn handle_mcp_stream_upgrade( }; tokio::spawn(async move { - if let Err(e) = - handle_mcp_stream_connection(websocket, mcp_state, active_api_connections).await + if let Err(e) = handle_mcp_stream_connection( + websocket, + serve_session, + mcp_state, + active_api_connections, + ) + .await { log::error!("MCP stream WebSocket error: {e}"); } @@ -3553,6 +3565,7 @@ async fn handle_mcp_stream_upgrade( async fn handle_mcp_stream_connection( websocket: HyperWebsocket, + serve_session: Arc, mcp_state: Arc, active_api_connections: Arc, ) -> anyhow::Result<()> { @@ -3577,6 +3590,61 @@ async fn handle_mcp_stream_connection( if let Ok(greeting) = serde_json::from_str::(&text) { if greeting.get("type").and_then(|t| t.as_str()) == Some("hello") { if let Ok(config) = serde_json::from_value::(greeting.clone()) { + // Validate place ID against the project's servePlaceIds / blockedPlaceIds. + if let Some(place_id_f64) = config.place_id { + let place_id = place_id_f64 as u64; + if let Some(expected) = serve_session.serve_place_ids() { + if !expected.contains(&place_id) { + log::trace!( + "MCP stream rejected: place ID {} not in servePlaceIds", + place_id + ); + let reject = serde_json::json!({ + "type": "rejected", + "reason": format!( + "Place ID {} is not in the servePlaceIds whitelist", + place_id + ), + }); + let _ = websocket + .send(Message::Text( + serde_json::to_string(&reject).unwrap().into(), + )) + .await; + let _ = websocket.send(Message::Close(None)).await; + mcp_state + .plugin_stream_connected + .store(false, Ordering::SeqCst); + return Ok(()); + } + } + if let Some(blocked) = serve_session.blocked_place_ids() { + if blocked.contains(&place_id) { + log::trace!( + "MCP stream rejected: place ID {} is in blockedPlaceIds", + place_id + ); + let reject = serde_json::json!({ + "type": "rejected", + "reason": format!( + "Place ID {} is in the blockedPlaceIds list", + place_id + ), + }); + let _ = websocket + .send(Message::Text( + serde_json::to_string(&reject).unwrap().into(), + )) + .await; + let _ = websocket.send(Message::Close(None)).await; + mcp_state + .plugin_stream_connected + .store(false, Ordering::SeqCst); + return Ok(()); + } + } + } + let mut slot = mcp_state .plugin_config .lock() diff --git a/src/web/mcp.rs b/src/web/mcp.rs index d5a5a17f..1970162e 100644 --- a/src/web/mcp.rs +++ b/src/web/mcp.rs @@ -21,6 +21,8 @@ pub struct PluginConfig { pub one_shot_sync: bool, #[serde(default)] pub confirmation_behavior: String, + #[serde(default)] + pub place_id: Option, } /// Command sent from the MCP handler to the plugin via the MCP stream WebSocket.