- Introduced a new `get_script` command to allow agents to read script source code directly from Roblox Studio, enhancing conflict resolution during sync operations. - Updated the MCP stream handling to support the new command, including error handling for missing or invalid parameters. - Enhanced the App component to manage the `get_script` command and its response, ensuring proper integration with the existing MCP functionality. - Updated the TODO list to reflect the addition of the `get_script` command and its associated documentation requirements. These changes aim to improve the overall functionality and user experience of the MCP integration, particularly in script management.
21 KiB
| name | overview | todos | isProject | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| MCP sync endpoint enhancement | Enhance the `atlas_sync` MCP tool to support modes (standard/manual/fastfail/dryrun), agent-specified override auto-accepts with hash + property verification, and enriched change metadata (studioHash, property before/after values, className, patchType, fsPath) in the response. |
|
false |
MCP Sync Endpoint Enhancement
Context
The current atlas_sync tool takes no parameters and has one behavior: if all changes are git-auto-selected, fast-forward; otherwise show Studio UI and block. The agent has no way to control this, no way to auto-accept specific changes, and gets minimal metadata back.
The enhanced endpoint enables an agent workflow like:
atlas_sync(mode: "fastfail")-- get changes with hashes, fail if any are unresolvedget_script(id)-- read conflicting script from Studio (future endpoint, usesidfrom sync response)- Merge changes, write to filesystem
atlas_sync(overrides: [{id, direction, studioHash}])-- auto-accept with hash verification
New Input Schema
{
"mode": "standard",
"overrides": [
{
"id": "00abcdef01234567890abcdef0123456",
"direction": "push",
"studioHash": "a1b2c3...",
"expectedProperties": {
"Disabled": false
}
},
{
"id": "00abcdef01234567890abcdef0123457",
"direction": "push",
"expectedProperties": {
"Position": {"Vector3": [0, 5, 0]},
"Anchored": true
}
}
]
}
- mode:
"standard"(default) |"manual"|"fastfail"|"dryrun" - overrides: array of auto-accept directives per instance:
id(required): the server Ref (32-char hex) from a previous sync response. Matched directly viapatchTree:getNode(id)-- O(1), no ambiguity possible.direction(required):"push"or"pull"studioHash(scripts only): SHA1 of git blob of Source, verifies Source hasn't changed since agent last inspectedexpectedProperties(optional): map of property name to expected current Studio value. Plugin verifies each against the live instance. Uses RbxDom encoded format (same as response). If any mismatch, the override is rejected and the change becomes unresolved.
Why id instead of path
Instance names are not unique in Roblox -- a parent can have multiple children with the same name. Paths built from instance.Name segments (e.g. Workspace/Part) are ambiguous when duplicates exist. The server Ref (id) is a unique 128-bit identifier per instance, assigned by the server and round-tripped through all responses. Using it eliminates the path ambiguity problem entirely on both the response and override sides, with no need for path escaping or debugId disambiguation.
The path field in responses remains for human readability (agents can display it, use it in logs). The fsPath field provides the unique Atlas filesystem path for file editing. Neither is used for override matching.
New Response Change Schema
Currently each change is {path, direction}. Enhanced:
{
"path": "ServerScriptService/MyScript",
"id": "00abcdef...",
"direction": "push",
"patchType": "Edit",
"className": "Script",
"studioHash": "a1b2c3...",
"defaultSelection": "push",
"fsPath": "src/server/MyScript.server.luau",
"properties": {
"Disabled": {
"current": false,
"incoming": true
}
}
}
For a non-script instance with property changes:
{
"path": "Workspace/MyPart",
"id": "00abcdef...",
"direction": "push",
"patchType": "Edit",
"className": "Part",
"defaultSelection": "pull",
"fsPath": "src/workspace/MyPart.model.json5",
"properties": {
"Position": {
"current": {"Vector3": [0, 5, 0]},
"incoming": {"Vector3": [10, 5, 0]}
},
"Anchored": {
"current": false,
"incoming": true
}
}
}
studioHash: SHA1 of"blob <len>\0<source>"(git blob format) of the script's Source in Studio. Only present for script classes. Used by agents for override verification.fsPath: Atlas filesystem path relative to project root. Enriched server-side fromInstigatingSource::Path. Null for project-node instances or additions.defaultSelection: what git auto-selected ("push"/"pull"/ null)properties: map of changed property name to{current, incoming}values in RbxDom encoded format.currentis the live Studio value,incomingis the Atlas value. For scripts, Source is omitted fromproperties(usestudioHash+get_scriptinstead to avoid massive payloads). Only present for Edit patchType.
Mode Behavior (plugin confirm callback)
| Mode | Behavior |
|---|---|
standard |
Current: fast-forward if all pre-selected, otherwise show UI |
manual |
Always show UI, never auto-accept |
fastfail |
If any change has null defaultSelection AND no matching override, resolve immediately with fastfail_unresolved status |
dryrun |
Compute full change list with hashes, resolve immediately, abort session without applying |
Override Matching and Verification (plugin side)
For each override, the plugin:
- Match by
id: Look up the PatchTree node viapatchTree:getNode(override.id). If not found (stale id from a previous session), skip the override. - studioHash verification (scripts): If
override.studioHashis provided, compute SHA1 of git blob ofnode.instance.Sourceand compare. Mismatch = override rejected (someone changed the script since the agent last looked). - expectedProperties verification (any instance): If
override.expectedPropertiesis provided, for each property in the map: a. Decode the expected value viadecodeValue(expectedValue, instanceMap)-- the override arrives as RbxDom-encoded JSON (e.g.{"Vector3": [0, 5, 0]}), which must be decoded to a live Roblox value before comparison. b. Read the current Studio value viagetProperty(node.instance, propName). c. Compare usingtrueEquals(decodedExpected, currentValue)-- fuzzy float comparison, EnumItem handling, etc. d. Any mismatch = override rejected (someone changed a property since the agent last looked). - If all verifications pass, set the node's selection to
override.direction, treating it as pre-selected for mode logic. - If verification fails, the node remains unresolved. In
fastfailmode this means the sync fails immediately with the unresolved changes listed. Instandardmode the user sees them in the Studio UI.
This design ensures: no path ambiguity (id is unique), no path escaping needed, O(1) node lookup, and the agent never silently overwrites concurrent changes from other developers.
Files to Change
Rust: [src/web/mcp.rs](src/web/mcp.rs)
- Add
SyncOverridestruct:{id, direction, studio_hash?, expected_properties?} - Expand
McpSyncCommand: addmode: String,overrides: Vec<SyncOverride> - Expand
SyncChange: addid,class_name,patch_type,studio_hash,default_selection,fs_path,properties(all optional) - Update
atlas_synctool definition inhandle_tools_listwith new inputSchema - Update
handle_atlas_syncto parse mode + overrides from params and include them in the command - Handle new statuses (
fastfail_unresolved,dryrun) in response formatting - Update
handle_atlas_syncresponse text to include richer change info (structured JSON instead of plain text list) - Update tests
Rust: [src/web/api.rs](src/web/api.rs) (MCP stream handler)
- Serialize mode + overrides into the WebSocket command to the plugin
- After receiving
McpSyncResult, enrich each change'sfs_path:- Attempt to parse
change.idasRef(32-char hex, viaRef::from_str). If parse fails (e.g. plugin-generated GUID for removed nodes), leavefs_path = Noneand continue. - Look up
tree.get_instance(ref).metadata.instigating_source - If
InstigatingSource::Path(p), strip project root prefix, setfs_path - If instance not found or source is
ProjectNode, leavefs_path = None
- Attempt to parse
- Forward enriched result via oneshot
Plugin: [plugin/src/McpStream.lua](plugin/src/McpStream.lua)
- Forward
data.modeanddata.overridesfrom the sync command toself._onSyncCommand(data.requestId, data.mode, data.overrides)
Plugin: [plugin/src/App/init.lua](plugin/src/App/init.lua)
**startMcpSync(requestId, mode, overrides):**
- Accept
modeandoverridesparameters - In the confirm callback (which receives
instanceMap, patch, serverInfo), after building PatchTree:- Apply overrides: for each override, look up node by
patchTree:getNode(override.id), verify studioHash for scripts, verify expectedProperties viadecodeValue+trueEquals, set selection dryrun: build full change list via_buildMcpChangeList(patchTree, patch, instanceMap, nil, true), resolve withstatus: "dryrun", return"Abort"fastfail: check if any node is unresolved after defaults + overrides; if so, resolve withfastfail_unresolved+ full change list, return"Abort"manual: force into user-review flow (skip theallPreSelectedfast-forward)standard: current behavior
- Apply overrides: for each override, look up node by
- All calls to
_buildMcpChangeListin this function must passpatchandinstanceMap(available as closure variables from the confirm callback)
**_buildMcpChangeList(patchTree, patch, instanceMap, selections, includeAll):**
Signature change: receives the original patch object and instanceMap in addition to the tree, because:
node.changeListstores decoded Roblox values (Vector3 userdata, EnumItem, etc.) that cannot be JSON-serialized- We need the raw
patch.updated[].changedPropertiesfor incoming values (already in RbxDom-encoded wire format) - We need
encodeProperty(instance, propName, descriptor)to encode current Studio values into the same wire format - Both require looking up the
RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propName)for each property
Implementation:
- Add
includeAllparameter (for dryrun/fastfail: include all changes regardless of selection) - Add
className = node.className - Add
patchType = node.patchType - Add
id = node.id(server Ref, for fsPath enrichment). Note: for Removed nodes, this may be a plugin-generated GUID rather than a server Ref -- the RustfsPathenrichment must handleRef::from_strparse failures gracefully (setfsPath = null). - Add
defaultSelection = node.defaultSelection - For script instances (
node.instance:IsA("LuaSourceContainer")): compute and addstudioHash= SHA1 of git blob ofinstance.Source - Add
propertiesmap for Edit nodes: build apatchLookupfrompatch.updatedkeyed bychange.id, then for each property inpatchLookup[node.id].changedProperties:- Skip
Sourcefor script classes (handled bystudioHash+ futureget_script) incoming: use the raw value fromchangedProperties[propName](already RbxDom-encoded wire format, JSON-safe)current: look up descriptor viaRbxDom.findCanonicalPropertyDescriptor(node.className, propName), then callencodeProperty(node.instance, propName, descriptor)to get the RbxDom-encoded current value- Store as
{current = encodedCurrent, incoming = encodedIncoming} - If encoding fails for either side, skip that property (don't crash the response)
- Skip
New Statuses
| Status | When | isError |
|---|---|---|
dryrun |
Dryrun mode, returning what would happen | false |
fastfail_unresolved |
Fastfail mode, unresolved changes exist | true |
(existing) success, empty, rejected, error, etc. |
Unchanged | Unchanged |
Response Format Enhancement
Currently the MCP tool response is plain text. After this change, for all statuses that include changes (success, fastfail, dryrun, rejected), include a structured JSON block inside the text content so the agent can parse it:
Sync completed successfully.
Changes:
- [push] ServerScriptService/MyScript (Edit, Script, fs=src/server/MyScript.server.luau)
- [pull] Workspace/MyPart (Edit, Part, fs=src/workspace/MyPart.model.json5)
<json>
{"status":"success","changes":[{"path":"ServerScriptService/MyScript","id":"00ab...","direction":"push","patchType":"Edit","className":"Script","studioHash":"abc123","defaultSelection":"push","fsPath":"src/server/MyScript.server.luau","properties":{"Disabled":{"current":false,"incoming":true}}},{"path":"Workspace/MyPart","id":"00cd...","direction":"pull","patchType":"Edit","className":"Part","defaultSelection":"pull","fsPath":"src/workspace/MyPart.model.json5","properties":{"Position":{"current":{"Vector3":[0,5,0]},"incoming":{"Vector3":[10,5,0]}}}}]}
</json>
The <json> block gives the agent machine-readable data while keeping the human-readable summary above it. Note: script Source is deliberately excluded from properties to avoid massive payloads -- agents use studioHash for verification and the future get_script tool to read actual source content when needed.
Test Suite
Full coverage testing across both Rust and Lua, organized by layer.
Rust: src/web/mcp.rs unit tests
Wire type serde (extend existing wire_types module):
SyncOverrideserializes/deserializes with camelCase (id,direction,studioHash,expectedProperties)SyncOverridedefaults: missingstudioHashandexpectedPropertiesdeserialize toNoneMcpSyncCommandserializesmodeandoverridesfieldsSyncChangeserializes all new optional fields (id,className,patchType,studioHash,defaultSelection,fsPath,properties)SyncChangeomitsNoneoptional fields (skip_serializing_if)McpSyncResultwith enrichedSyncChangeentries round-trips correctly
Handler tests (extend existing handler_tests module):
handle_tools_listreturns updated inputSchema withmodeandoverrideshandle_atlas_syncparsesmodefrom params and passes through to commandhandle_atlas_syncparsesoverridesfrom params and passes through to commandhandle_atlas_syncdefaultsmodeto"standard"when omittedhandle_atlas_syncdefaultsoverridesto empty array when omitted
Guard tests (extend existing atlas_sync_guards module):
- Success result with enriched changes includes
<json>block in response text - Dryrun status maps to
isError: false fastfail_unresolvedstatus maps toisError: true- Empty status still returns
isError: false - Rejected with enriched changes includes
<json>block
Rust: src/web/api.rs fsPath enrichment tests
- Valid Ref resolves to
InstigatingSource::Path-> fsPath is set (relative to project root) - Valid Ref with
InstigatingSource::ProjectNode-> fsPath is None - Invalid Ref string (plugin GUID) -> fsPath is None, no panic
- Ref for instance not in tree -> fsPath is None
- Multiple changes: only those with valid paths get fsPath
Plugin: _buildMcpChangeList tests (plugin/src/App/init.spec.lua or dedicated file)
Basic enrichment:
- Edit node includes
id,className,patchType,defaultSelection - Add node includes
id,className,patchType = "Add" - Remove node includes
id,className,patchType = "Remove" - Script node includes
studioHash(correct git blob SHA1) - Non-script node has no
studioHash
Property encoding:
- Edit node with
changedPropertiesproducespropertiesmap with{current, incoming}in RbxDom-encoded format Sourceis skipped for script classesNamechange appears in properties aschangedName- Failed encode for current value skips that property (no crash)
- Failed encode for incoming value skips that property (no crash)
- Multiple property types: Bool, Number, String, Vector3, Color3, Enum
includeAll parameter:
includeAll = falsewith selections: only includes selected nodesincludeAll = falsewithout selections: includes all with defaultSelection or "push"includeAll = true: includes every node with patchType regardless of selection
Plugin: Override verification tests
id-based matching:
- Override with valid node id matches correctly
- Override with unknown id is silently skipped
- Multiple overrides match their respective nodes
studioHash verification:
- Matching hash: override accepted, node selection set
- Mismatching hash: override rejected, node stays unresolved
- No studioHash on override for script: override accepted (no verification)
- studioHash on non-script: ignored (only Source hashing applies to scripts)
expectedProperties verification:
- All properties match: override accepted
- One property mismatch: override rejected entirely
- Empty expectedProperties: override accepted (no verification)
- Property value decoded via
decodeValuebeforetrueEqualscomparison - Fuzzy float comparison works (e.g.
0.30000001matches0.3) - EnumItem comparison works (e.g.
{Enum = 2}vsEnum.Material.Plastic)
Plugin: Mode behavior tests
standard mode:
- All pre-selected: fast-forwards, resolves
success, returns Confirm with selections - Some unresolved: shows Confirming UI, waits for user
- Empty patch: resolves
empty
manual mode:
- All pre-selected: still shows Confirming UI (no fast-forward)
- User confirms: resolves
success - User aborts: resolves
rejected
fastfail mode:
- All resolved (via defaults + overrides): fast-forwards, resolves
success - Some unresolved after defaults + overrides: resolves
fastfail_unresolvedimmediately, returns"Abort", includes full change list withincludeAll = true - Override verification failure makes node unresolved, triggers fastfail
dryrun mode:
- Resolves immediately with
status: "dryrun", returns"Abort" - Change list includes all nodes with full metadata (properties, studioHash)
- Session is aborted without applying any changes
- Works with empty patch (resolves
empty)
Plugin: McpStream forwarding tests
{type: "sync", requestId, mode, overrides}forwards all three fields toonSyncCommand- Missing
modedefaults to"standard"(or nil, handled bystartMcpSync) - Missing
overridesdefaults to empty table - Result is JSON-encoded and sent back over WebSocket