Enhance MCP functionality and improve syncback performance

- Expanded the TODO list with new tasks related to MCP modes, including standard, manual, fastfail, and dryrun options for better user control during sync operations.
- Introduced a new `get_script` command to allow agents to read scripts directly from Roblox Studio, facilitating conflict resolution during sync.
- Implemented a `GitIndexCache` to optimize file writes by skipping unchanged files based on blob SHA1 comparisons, improving syncback efficiency.
- Added logging for agent connections and commands to enhance traceability and debugging capabilities.

These changes aim to improve the overall functionality and performance of the MCP integration and syncback process.
This commit is contained in:
Joe 2026-03-01 08:44:26 -06:00
parent 581aeaeffa
commit 99b0af36fd
7 changed files with 170 additions and 15 deletions

7
.cursor/mcp.json Normal file
View file

@ -0,0 +1,7 @@
{
"mcpServers": {
"atlas": {
"url": "http://localhost:34873/mcp"
}
}
}

View file

@ -328,9 +328,37 @@ in all 3 matchers, simply randomize the order of the children - this will break
can we allow the terminal to stay so i can like run some git commands myself without having to make a whole new terminal? 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] [mcp]
* sync
- add modes:
- standard (default): what we currently have where if there are changes it waits for the user to act
- manual: user must always interact
- fastfail: if there are non auto selected changes, immediately fail without waiting for the user to reject
- dryrun: just checks what would be done and then returns out immediately
- provide the agent hashes of the current scripts on studio involved in the merge
- allow the agent to specify patches it wants to accept automatically
- for sciprts, need to include a hash of the file ON roblox studio currently to signify there wasn't MORE new changes in the mid-term
- for properties, just include what we are accepting
* get_script PATH [from draft?]
- allows the agent to read a script currently on studio
- by default directly reads the Source property on the script
- optional to allow the agent to read the draft
- this is needed when an agent tries to sync and it fails due to a conflict since we don't send back sources in the failure
- agen't can complete merges by itself with properties, so we don't need a way for the agent to read properties for things
- the PATH will be: ReplicatedStorage/Some/Path/Abc - like a direct instance path, not a atlas aware path.
- we should steal this system fully: D:\UserGenerated\studio-rust-mcp-server\plugin\Utils\Paths.luau
- in the sync feedback it gives, we should include Paths.GetRelativePath so the agent is able to find it via the read_script ALONGSIDE the current info we can give so it can also find it on the Atlas filesystem
- investigate this further, because i just want to give the agent the best possible way to query roblox instances, and also atlas instances on the filesystem. if using this Paths module is an overstep and we can accomplish it with a fully atlas-based approach, that's fine - aslong as it works CONSISTENTLY and is simple.
* port all of the basic MCP endpoints offered via https://github.com/Roblox/studio-rust-mcp-server
- integrate them into how we've done our mcp in atlas
- make sure to handle the placeid filtering like we already do when establishing the connection
* syncback * syncback
- invokes plugin based syncback - invokes plugin based syncback
- ensure this doesn't interrupt the connection of the mcp, since i think we shutdown the api? we should just not shutdown the api or something - ensure this doesn't interrupt the connection of the mcp, since i think we shutdown the api? we should just not shutdown the api or something
* add logging for all agent connect/disconnects in the terminal
- also add logging for commands (including the json) it runs and the resulting json. try to keep this on one line please, json compact.
[sync]
right now sync is running REALLY slow
[automated review of all changes done, similar to cursor agent review for the game] [automated review of all changes done, similar to cursor agent review for the game]
it's a discord bot it's a discord bot

View file

@ -1787,6 +1787,18 @@ impl JobThreadContext {
if let Some(ref write_path) = source_path { if let Some(ref write_path) = source_path {
if let Some(Variant::String(value)) = changed_value { if let Some(Variant::String(value)) = changed_value {
if let Ok(existing) = fs::read(write_path) {
if existing == value.as_bytes() {
log::debug!(
"Two-way sync: Skipping unchanged Source for {}",
self.display_path(write_path)
);
if patch_set.stage_ids.contains(&id) {
pending_stage_paths.push(write_path.clone());
}
continue;
}
}
log::info!( log::info!(
"Two-way sync: Writing Source to {}", "Two-way sync: Writing Source to {}",
self.display_path(write_path) self.display_path(write_path)

View file

@ -120,9 +120,10 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R
drop(dom_old); drop(dom_old);
log::info!("Writing to the file system..."); log::info!("Writing to the file system...");
let git_cache = crate::git::GitIndexCache::new(base_path);
result result
.fs_snapshot .fs_snapshot
.write_to_vfs_parallel(base_path, session_old.vfs())?; .write_to_vfs_parallel(base_path, session_old.vfs(), git_cache.as_ref())?;
log::info!( log::info!(
"Finished live syncback: wrote {} files/folders, removed {}.", "Finished live syncback: wrote {} files/folders, removed {}.",

View file

@ -233,6 +233,8 @@ impl SyncbackCommand {
log::info!("Writing to the file system..."); log::info!("Writing to the file system...");
let git_cache = crate::git::GitIndexCache::new(base_path);
if self.sourcemap { if self.sourcemap {
let sourcemap_path = base_path.join("sourcemap.json"); let sourcemap_path = base_path.join("sourcemap.json");
@ -241,9 +243,11 @@ impl SyncbackCommand {
// so it doesn't need to wait for files to be on disk. // so it doesn't need to wait for files to be on disk.
let (write_result, sourcemap_result) = std::thread::scope(|s| { let (write_result, sourcemap_result) = std::thread::scope(|s| {
let write_handle = s.spawn(|| { let write_handle = s.spawn(|| {
result result.fs_snapshot.write_to_vfs_parallel(
.fs_snapshot base_path,
.write_to_vfs_parallel(base_path, session_old.vfs()) session_old.vfs(),
git_cache.as_ref(),
)
}); });
let sourcemap_handle = s.spawn(|| { let sourcemap_handle = s.spawn(|| {
@ -268,9 +272,11 @@ impl SyncbackCommand {
Err(e) => log::warn!("Could not generate sourcemap: {}", e), Err(e) => log::warn!("Could not generate sourcemap: {}", e),
} }
} else { } else {
result result.fs_snapshot.write_to_vfs_parallel(
.fs_snapshot base_path,
.write_to_vfs_parallel(base_path, session_old.vfs())?; session_old.vfs(),
git_cache.as_ref(),
)?;
} }
log::info!( log::info!(

View file

@ -383,6 +383,67 @@ pub fn compute_blob_sha1(content: &str) -> String {
format!("{:x}", hasher.finalize()) format!("{:x}", hasher.finalize())
} }
pub fn compute_blob_sha1_bytes(content: &[u8]) -> String {
let header = format!("blob {}\0", content.len());
let mut hasher = Sha1::new();
hasher.update(header.as_bytes());
hasher.update(content);
format!("{:x}", hasher.finalize())
}
/// Pre-computed git index blob hashes for fast content comparison.
///
/// During syncback, we can avoid writing files whose content matches what's
/// already on disk. For files tracked in the git index, comparing the blob
/// SHA1 of the new content against the index entry's hash avoids reading
/// the file from disk entirely.
pub struct GitIndexCache {
entries: HashMap<PathBuf, String>,
}
impl GitIndexCache {
pub fn new(project_root: &Path) -> Option<Self> {
let repo = open_repo(project_root)?;
let repo_root = repo.workdir()?.to_owned();
let index = repo.open_index().ok()?;
let mut entries = HashMap::new();
for entry in index.entries() {
let path_bstr = entry.path(&index);
let path_str = path_bstr.to_str().ok()?;
let rel_path = PathBuf::from(path_str);
let hash_hex = entry.id.to_hex().to_string();
let abs_path = repo_root.join(&rel_path);
let project_rel = abs_path
.strip_prefix(project_root)
.unwrap_or(&rel_path)
.to_path_buf();
entries.insert(project_rel, hash_hex);
}
log::debug!("GitIndexCache: loaded {} index entries", entries.len());
Some(Self { entries })
}
/// Returns `true` if the new content's blob SHA1 matches the git index
/// entry for this path, meaning the file on disk almost certainly
/// already has this content.
pub fn file_matches_index(&self, rel_path: &Path, content: &[u8]) -> bool {
let normalized = PathBuf::from(rel_path.to_string_lossy().replace('\\', "/"));
let index_hash = match self
.entries
.get(&normalized)
.or_else(|| self.entries.get(rel_path))
{
Some(h) => h,
None => return false,
};
let content_hash = compute_blob_sha1_bytes(content);
&content_hash == index_hash
}
}
pub fn git_add(repo_root: &Path, paths: &[PathBuf]) { pub fn git_add(repo_root: &Path, paths: &[PathBuf]) {
if paths.is_empty() { if paths.is_empty() {
return; return;

View file

@ -11,6 +11,8 @@ use std::{thread, time::Duration};
use memofs::Vfs; use memofs::Vfs;
use rayon::prelude::*; use rayon::prelude::*;
use crate::git::GitIndexCache;
/// Maximum number of retry attempts for filesystem operations on Windows. /// Maximum number of retry attempts for filesystem operations on Windows.
/// Windows can have transient "Access denied" errors due to antivirus scanning, /// Windows can have transient "Access denied" errors due to antivirus scanning,
/// filesystem timing, or file handle release delays. /// filesystem timing, or file handle release delays.
@ -274,16 +276,32 @@ impl FsSnapshot {
/// Directory creation and removal remain sequential (ordering constraints), /// Directory creation and removal remain sequential (ordering constraints),
/// but file writes and file removals are parallelized using rayon. /// but file writes and file removals are parallelized using rayon.
/// ///
/// Files whose content already matches what's on disk are skipped. When a
/// `GitIndexCache` is provided, tracked files are checked via blob SHA1
/// comparison (no disk read needed); all other files fall back to reading
/// the existing content and comparing bytes.
///
/// This bypasses the VFS lock for file writes, using `std::fs` directly. /// This bypasses the VFS lock for file writes, using `std::fs` directly.
/// This is safe because syncback uses a oneshot VFS with no caching or watching. /// This is safe because syncback uses a oneshot VFS with no caching or watching.
pub fn write_to_vfs_parallel<P: AsRef<Path>>(&self, base: P, vfs: &Vfs) -> io::Result<()> { pub fn write_to_vfs_parallel<P: AsRef<Path>>(
&self,
base: P,
vfs: &Vfs,
git_cache: Option<&GitIndexCache>,
) -> io::Result<()> {
let base_path = base.as_ref(); let base_path = base.as_ref();
// Phase 1: Create directories (sequential - parent must exist before child) // Phase 1: Create directories (sequential - parent must exist before child)
let skipped_dirs = AtomicUsize::new(0);
{ {
let mut lock = vfs.lock(); let mut lock = vfs.lock();
for dir_path in &self.added_dirs { for dir_path in &self.added_dirs {
match lock.create_dir_all(base_path.join(dir_path)) { let full = base_path.join(dir_path);
if full.is_dir() {
skipped_dirs.fetch_add(1, Ordering::Relaxed);
continue;
}
match lock.create_dir_all(full) {
Ok(_) => (), Ok(_) => (),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (), Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
Err(err) => return Err(err), Err(err) => return Err(err),
@ -292,13 +310,28 @@ impl FsSnapshot {
} // Release lock before parallel phase } // Release lock before parallel phase
// Phase 2: Write files (parallel - independent operations) // Phase 2: Write files (parallel - independent operations)
// On Windows, use retry logic for transient "Access denied" errors that can
// occur due to antivirus scanning or filesystem timing issues.
let write_errors = AtomicUsize::new(0); let write_errors = AtomicUsize::new(0);
let first_error: std::sync::Mutex<Option<io::Error>> = std::sync::Mutex::new(None); let first_error: std::sync::Mutex<Option<io::Error>> = std::sync::Mutex::new(None);
let skipped_files = AtomicUsize::new(0);
self.added_files.par_iter().for_each(|(path, contents)| { self.added_files.par_iter().for_each(|(path, contents)| {
let full_path = base_path.join(path); let full_path = base_path.join(path);
if let Some(cache) = git_cache {
if cache.file_matches_index(path, contents) {
skipped_files.fetch_add(1, Ordering::Relaxed);
return;
}
}
match std::fs::read(&full_path) {
Ok(existing) if existing == *contents => {
skipped_files.fetch_add(1, Ordering::Relaxed);
return;
}
_ => {}
}
if let Err(err) = write_with_retry(&full_path, contents) { if let Err(err) = write_with_retry(&full_path, contents) {
write_errors.fetch_add(1, Ordering::Relaxed); write_errors.fetch_add(1, Ordering::Relaxed);
let mut guard = first_error.lock().unwrap(); let mut guard = first_error.lock().unwrap();
@ -369,10 +402,17 @@ impl FsSnapshot {
} }
} }
log::debug!( let skipped_file_count = skipped_files.load(Ordering::Relaxed);
"Wrote {} directories and {} files to the file system (parallel)", let skipped_dir_count = skipped_dirs.load(Ordering::Relaxed);
self.added_dirs.len(), let written_files = self.added_files.len() - skipped_file_count;
self.added_files.len() let created_dirs = self.added_dirs.len() - skipped_dir_count;
log::info!(
"Wrote {} files and {} directories ({} files and {} directories unchanged, skipped)",
written_files,
created_dirs,
skipped_file_count,
skipped_dir_count,
); );
let files_inside_dirs = self.removed_files.len() - files_to_remove.len(); let files_inside_dirs = self.removed_files.len() - files_to_remove.len();