- 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.
14 KiB
| name | overview | todos | isProject | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| MCP get_script endpoint | Add a `get_script` MCP tool that reads script source from Studio. Accepts `id` (server Ref) or `fsPath` (resolved server-side to id via RojoTree). Plugin uses a shallow copy of instanceMap.fromIds retained from the last MCP sync for O(1) lookup. |
|
false |
MCP get_script Endpoint
Design
The agent provides id (server Ref from a sync response) or fsPath (Atlas filesystem path). Both resolve to a server Ref which the plugin looks up via a shallow copy of instanceMap.fromIds retained from the last MCP sync. No raw instance paths, no QueryDescendants, no name ambiguity issues.
This endpoint exists for conflict resolution: agent calls atlas_sync, gets a conflict response with id and fsPath per change, then calls get_script to read the Studio version of conflicting scripts. The studioHash in the response feeds back into subsequent sync override verification.
sequenceDiagram
participant Agent
participant mcpRs as mcp.rs
participant apiRs as api.rs
participant Plugin as Plugin McpStream
Agent->>mcpRs: get_script(id OR fsPath, fromDraft?)
mcpRs->>apiRs: command_tx (Value)
Note over apiRs: If fsPath: resolve to id via RojoTree
apiRs->>Plugin: {type: "getScript", requestId, id, fromDraft}
Plugin->>Plugin: self._lastMcpFromIds[id] (O(1))
Plugin->>Plugin: Read Source (or draft), compute SHA1
Plugin->>apiRs: {requestId, status, source, studioHash, className, instancePath}
Note over apiRs: Enrich with id, fsPath
apiRs->>mcpRs: result_tx (Value)
mcpRs->>Agent: Tool response with source + metadata
Channel Generalization
Change McpSyncState channels from typed structs to serde_json::Value so any command/result can flow through. This enables get_script and future endpoints without additional channel plumbing.
- Rename
McpSyncState->McpState - Rename
sync_in_progress->command_in_progress(any MCP command blocks others) command_tx: watch::Sender<Option<Value>>(wasOption<McpSyncCommand>)result_tx: Mutex<Option<oneshot::Sender<Value>>>(wasoneshot::Sender<McpSyncResult>)
Each handler serializes its command struct to Value before sending, deserializes from Value on response.
Retained InstanceMap (Shallow Copy)
Critical: InstanceMap:stop() iterates all entries and calls removeInstance, which sets fromIds[id] = nil for every entry. This is called by ServeSession:__stopInternal() during endSession(). Storing a reference to the InstanceMap object or its fromIds table gives an empty map after session teardown.
Solution: At the top of setConfirmCallback (before any branch that could trigger session teardown), make a shallow copy of fromIds:
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
local retainedFromIds = {}
for id, inst in instanceMap.fromIds do
retainedFromIds[id] = inst
end
self._lastMcpFromIds = retainedFromIds
-- ... rest of existing callback logic
end)
This copy:
- Survives
InstanceMap:stop()because it's a separate table - Is O(N) but fast (just string key -> Instance ref copies, typical project has a few thousand entries)
- Is replaced on the next MCP sync (each sync creates a fresh copy)
- Stale entries handled gracefully: if
instance.Parent == nil, the instance was destroyed since the sync - Cleared when MCP stream disconnects (
self._lastMcpFromIds = nil)
Note: The already_connected early return (line 1131-1138 in startMcpSync) does NOT create a ServeSession, so _lastMcpFromIds is not updated in that case -- it retains whatever value it had from the last actual sync. This is correct behavior.
get_script Input Schema
{
"id": "00abcdef01234567890abcdef0123456",
"fsPath": "src/server/MyScript.server.luau",
"fromDraft": false
}
id: Server Ref (32-char hex) from a previous sync response. Looked up via retained_lastMcpFromIdson plugin.fsPath: Atlas filesystem path relative to project root. Resolved server-side toidvia RojoTree.- Priority:
id>fsPath. At least one required. fromDraft: If true, read unsaved editor content viaScriptEditorService:GetEditorSource. Default: false.
get_script Response
Script source for ServerScriptService/MyScript (Script):
Hash: a1b2c3d4e5...
<json>
{"status":"success","source":"print('hello')","studioHash":"a1b2c3...","className":"Script","instancePath":"ServerScriptService/MyScript","id":"00abcdef...","fsPath":"src/server/MyScript.server.luau","isDraft":false}
</json>
instancePath: Human-readable instance path derived frominstance:GetFullName()on the plugin side, reformatted fromgame.A.B.CtoA/B/C. Included for readability and logging; not used for any lookup.
Tool Description
The get_script tool definition in handle_tools_list must include a comprehensive description that teaches the agent the full workflow:
Read a script's source code from Roblox Studio. The id and fsPath parameters come from
a previous atlas_sync response's change entries.
If you haven't run atlas_sync yet, call atlas_sync(mode: "dryrun") first -- this
establishes the instance mapping without applying any changes, then you can call
get_script with the id or fsPath from that response.
Conflict resolution workflow:
1. atlas_sync(mode: "fastfail") -- get changes with studioHash, fail if unresolved
2. get_script(id: "...") -- read the Studio version of a conflicting script
3. Merge the Studio source with your local changes, write to filesystem
4. atlas_sync(overrides: [{id, direction: "push", studioHash: "...from get_script response..."}])
The studioHash in the response is the SHA1 of the git blob format of the script's Source
property. Pass it as the studioHash in sync overrides to verify the script hasn't changed
between your get_script call and the retry sync.
Server-Side Resolution (api.rs)
When api.rs receives a getScript command from the channel:
- If
fsPathpresent (and noid):project_root.join(fsPath)->tree.get_ids_at_path(&abs_path)-> get first Ref -> verify exists and is a script class -> inject asidin the command forwarded to plugin. Store fsPath for response enrichment. - If
idpresent: optionally verify it exists in the tree and is a script class. Pass through to plugin. Resolve fsPath from tree metadata for response enrichment. - On response from plugin: enrich with
id(Ref hex) andfsPath.
Plugin-Side Resolution
The plugin receives { type: "getScript", requestId, id, fromDraft }:
local fromIds = self._lastMcpFromIds
if not fromIds then
-- error: "No previous sync session. Run atlas_sync(mode: \"dryrun\") first to establish the instance mapping."
end
local instance = fromIds[id]
if not instance then
-- error: "Instance not found by id. The sync session may be stale, run atlas_sync again."
end
if instance.Parent == nil then
-- error: "Instance was deleted since last sync. Run atlas_sync again."
end
if not instance:IsA("LuaSourceContainer") then
-- error: "Instance is not a script (ClassName)"
end
After resolution:
- Read source: if
fromDraft, useScriptEditorService:GetEditorSource(instance); elseinstance.Source - Compute studioHash:
SHA1(buffer.fromstring("blob " .. #source .. "\0" .. source)) - Build instancePath:
string.gsub(instance:GetFullName(), "^game%.", ""):gsub("%.", "/") - Return
{ requestId, status = "success", source, studioHash, className = instance.ClassName, instancePath }
Guards
command_in_progressprevents concurrent MCP commands (channel has one result slot)plugin_stream_connectedrequired (otherwise no way to communicate with Studio)- NOT blocked by
active_api_connections > 0: In api.rs, theactive_api_connectionsguard must only apply tosynccommands, notgetScript. The command type routing checks thetypefield before this guard. Reading a script is safe during live sync; if no retained map exists, the plugin returns a clear error. - If
idorfsPathresolves to a non-script class in the tree, return error immediately from api.rs without plugin round-trip
Files to Change
Rust: [src/web/mcp.rs](src/web/mcp.rs)
- Rename
McpSyncState->McpState, generalize channels toValue - Add
GetScriptCommandstruct:{ request_id, id?, fs_path?, from_draft } - Add
GetScriptResultstruct:{ status, source?, studio_hash?, class_name?, instance_path?, id?, fs_path?, is_draft?, message? } - Add
get_scripttool definition inhandle_tools_listwith full workflow description and inputSchema (id, fsPath, fromDraft) - Add
handle_get_scripthandler (parse params, validate at least one of id/fsPath, send command, format response) - Update
handle_atlas_syncto serialize/deserialize via Value - Update all references from
McpSyncStatetoMcpState - Tests: wire type serde, handler parse, response formatting
Rust: [src/web/api.rs](src/web/api.rs)
- Update
McpSyncState->McpStatereferences - Update channel types to
Valuein MCP stream handler - Add command type routing: check
typefield on received command Value BEFORE theactive_api_connectionsguard.syncgoes through the guard;getScriptbypasses it. - For
getScript: resolve fsPath -> id via tree if needed, verify script class, forward to plugin withid, enrich result with id/fsPath on response - For
sync: existing behavior (forward, enrich fsPath on result)
Rust: [src/web/mod.rs](src/web/mod.rs) (if McpSyncState is referenced)
- Update type references
Plugin: [plugin/src/McpStream.lua](plugin/src/McpStream.lua)
- Add handler for
data.type == "getScript" - Call
self._onGetScriptCommand(data.requestId, data), expect Promise result - Send JSON result back over WebSocket (same pattern as sync)
Plugin: [plugin/src/App/init.lua](plugin/src/App/init.lua)
- Shallow copy instanceMap.fromIds: At the top of
setConfirmCallbackinstartMcpSync, copyinstanceMap.fromIdsintoself._lastMcpFromIds. This must happen before any branch (the copy survivesInstanceMap:stop()which clears the original). - Clear
self._lastMcpFromIds = nilwhen MCP stream disconnects. - Wire
onGetScriptCommandcallback in McpStream constructor. - Add
handleMcpGetScript(requestId, params)method:- Lookup via
self._lastMcpFromIds[id] - Handle missing map ("Run atlas_sync first")
- Handle stale entries (not found, Parent == nil)
- LuaSourceContainer check
- Source reading (regular + draft via ScriptEditorService)
- SHA1 hash computation (using existing
SHA1.luau) - Build instancePath from
instance:GetFullName() - Return Promise resolving to result table
- Lookup via
Test Plan
Rust unit tests (mcp.rs)
GetScriptResultserde round-trip with all fields (includinginstancePath)handle_tools_listincludesget_scripttool with correct inputSchema and workflow descriptionhandle_get_scriptreturns error when no plugin stream connectedhandle_get_scriptreturns error when command already in progresshandle_get_scriptrequires at least one of id/fsPath- Response formatting: success case includes
<json>block, error case setsisError
Rust integration (api.rs)
getScriptwithfsPath: resolves viaget_ids_at_path, injectsid, forwards to plugingetScriptwithid: passes through, resolvesfsPathfor enrichmentgetScriptwith non-scriptfsPath: returns error before forwardinggetScriptwith non-existentfsPath: returns errorgetScriptbypassesactive_api_connections > 0guard (command type routing)- Result enrichment adds
idandfsPath
Plugin tests
- Shallow copy survives
InstanceMap:stop(): copy retains entries after original is cleared - Id lookup: found, not found, stale (Parent nil), no retained map
- Non-script instance: returns error
- Source reading: regular source, draft source via ScriptEditorService
- StudioHash: correct git blob format SHA1
- instancePath: correctly derived from GetFullName
- Retained map cleared on MCP stream disconnect
already_connectedearly return does not update retained map