Enhance MCP tool functionality and improve logging capabilities

- Introduced new MCP tools for executing Luau code, inserting models, and retrieving console output, enhancing the plugin's capabilities for user interaction.
- Implemented a logging system to capture console output and provide detailed feedback during code execution and play mode operations.
- Updated the MCP stream to handle passthrough commands, allowing for better integration of tool commands and improved error handling.
- Enhanced the TODO list to reflect new tasks related to documentation for the added MCP tools and logging features.

These changes aim to improve the overall functionality and user experience of the MCP integration, particularly in script execution and model management.
This commit is contained in:
Joe 2026-03-01 11:56:05 -06:00
parent fd49602adb
commit fd990b86c6
24 changed files with 1464 additions and 12 deletions

View file

@ -345,15 +345,31 @@ can we allow the terminal to stay so i can like run some git commands myself wit
- integrate them into how we've done our mcp in atlas - 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 - make sure to handle the placeid filtering like we already do when establishing the connection
- we've done some actual fixes over at D:\UserGenerated\studio-rust-mcp-server on these, we should probably pull those fixed versions! we don't need that whole studio selection system from over there, though. - we've done some actual fixes over at D:\UserGenerated\studio-rust-mcp-server on these, we should probably pull those fixed versions! we don't need that whole studio selection system from over there, though.
* syncback - we don't need to restructure ours to be like this, but i like the .md doc setup.
- 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
* add logging for all agent connect/disconnects in the terminal * 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. - also add logging for commands (including the json) it runs and the resulting json. try to keep this on one line please, json compact.
* ensure all mcp route documentation is well formed and gives the agent knowledge on how to use this system to the fullest. * ensure all mcp route documentation is well formed and gives the agent knowledge on how to use this system to the fullest.
- I did .md based docs over with my D:\UserGenerated\studio-rust-mcp-server codebase, we should probably switch to this as it's cleaner - I did .md based docs over with my D:\UserGenerated\studio-rust-mcp-server codebase, we should probably switch to this as it's cleaner
- Instruct on good sync flow, with get_script merging. - Instruct on good sync flow, with get_script merging.
- If user requests get_script and we havn't sync'd yet, just do a dryrun sync to get the instance id map going. - If user requests get_script and we havn't sync'd yet, just do a dryrun sync to get the instance id map going.
* 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
[logging]
* I want to add verbose logging .atlas in the repository
* We should do rolling logs, and it should compress them automatically
* Will the existing logging infra take care of this?
* I want to have it include trace logging
* I also want to forward the roblox logs continously via the mcp connection and log them to the serve output, such they will end up in the rolling logs
- Make sure to retain the log-level (print, warn, error, etc)
- I'm open to having these actually divert and not send in the rust logging system and go to it's own file, and maybe a separate rust logger so we don't need to create our own infrastructure
- We could also just save logging output WHILST we are synced to the serve (would even work for one-shot) so as to not have a bunch of useless log info, though I think the mcp one is stronger.
* See: https://create.roblox.com/docs/reference/engine/classes/LogService
- We should adapt the rojo logger to route through this, as it offers better level info
- Listen to MessageOut to get the log info we are wanting.
- Is this actually the one we want? I might be confused, check the docs, this looks new.
* The goal with these trace logging is so that when others are using this tool, we can actually debug real issues by having them send me their logs.
[sync] [sync]
right now sync is running REALLY slow right now sync is running REALLY slow

View file

@ -38,6 +38,7 @@ local Dictionary = require(Plugin.Dictionary)
local ServeSession = require(Plugin.ServeSession) local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext) local ApiContext = require(Plugin.ApiContext)
local McpStream = require(Plugin.McpStream) local McpStream = require(Plugin.McpStream)
local McpTools = require(Plugin.McpTools)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local PatchTree = require(Plugin.PatchTree) local PatchTree = require(Plugin.PatchTree)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
@ -214,6 +215,24 @@ function App:init()
onGetScriptCommand = function(requestId, data) onGetScriptCommand = function(requestId, data)
return self:handleMcpGetScript(requestId, data) return self:handleMcpGetScript(requestId, data)
end, end,
onToolCommand = function(requestId, toolType, args)
return Promise.new(function(resolve)
local ok, result = pcall(McpTools.dispatch, toolType, args)
if ok then
resolve({
requestId = requestId,
status = "success",
response = result or "",
})
else
resolve({
requestId = requestId,
status = "error",
response = tostring(result),
})
end
end)
end,
getPluginConfig = function(key) getPluginConfig = function(key)
return Settings:get(key) return Settings:get(key)
end, end,

View file

@ -10,6 +10,15 @@ local strict = require(script.Parent.strict)
local RECONNECT_INTERVAL = 5 local RECONNECT_INTERVAL = 5
local PASSTHROUGH_TOOLS = {
run_code = true,
insert_model = true,
get_console_output = true,
get_studio_mode = true,
start_stop_play = true,
run_script_in_play_mode = true,
}
local McpStream = {} local McpStream = {}
McpStream.__index = McpStream McpStream.__index = McpStream
@ -18,6 +27,7 @@ function McpStream.new(options)
self._onSyncCommand = options.onSyncCommand self._onSyncCommand = options.onSyncCommand
self._onGetScriptCommand = options.onGetScriptCommand self._onGetScriptCommand = options.onGetScriptCommand
self._onToolCommand = options.onToolCommand
self._getPluginConfig = options.getPluginConfig self._getPluginConfig = options.getPluginConfig
self._wsClient = nil self._wsClient = nil
self._running = false self._running = false
@ -153,6 +163,33 @@ function McpStream:_tryConnect()
end) end)
end end
end) end)
elseif data.type and data.requestId and PASSTHROUGH_TOOLS[data.type] then
Log.info("MCP stream: received {} command (requestId={})", data.type, data.requestId)
local resultPromise = self._onToolCommand(data.requestId, data.type, data.args or {})
resultPromise
:andThen(function(result)
if self._wsClient then
local json = HttpService:JSONEncode(result)
pcall(function()
self._wsClient:Send(json)
end)
end
end)
:catch(function(err)
Log.warn("MCP stream: {} command failed: {}", data.type, tostring(err))
if self._wsClient then
local errorResult = HttpService:JSONEncode({
requestId = data.requestId,
status = "error",
response = tostring(err),
})
pcall(function()
self._wsClient:Send(errorResult)
end)
end
end)
end end
end) end)

View file

@ -0,0 +1,10 @@
--!strict
local ConsoleOutput = require(script.Parent.Utils.ConsoleOutput)
local function handleGetConsoleOutput(): string?
local result = ConsoleOutput.outputMessage or ""
return result
end
return handleGetConsoleOutput

View file

@ -0,0 +1,9 @@
--!strict
local StudioModeState = require(script.Parent.Utils.StudioModeState)
local function handleGetStudioMode(): string?
return StudioModeState.studioMode
end
return handleGetStudioMode

View file

@ -0,0 +1,135 @@
--!strict
--!nolint DeprecatedApi
local InsertService = game:GetService("InsertService")
type Args = {
query: string,
}
local INSERT_MAX_SEARCH_DEPTH = 2048
local INSERT_MAX_DISTANCE_AWAY = 20
local function getInsertPosition(): Vector3
local camera = workspace.CurrentCamera
local viewportPoint = camera.ViewportSize / 2
local unitRay = camera:ViewportPointToRay(viewportPoint.X, viewportPoint.Y, 0)
local ray = Ray.new(unitRay.Origin, unitRay.Direction * INSERT_MAX_SEARCH_DEPTH)
local params = RaycastParams.new()
params.BruteForceAllSlow = true
local result = workspace:Raycast(ray.Origin, ray.Direction, params)
if result then
return result.Position
else
return camera.CFrame.Position + unitRay.Direction * INSERT_MAX_DISTANCE_AWAY
end
end
type GetFreeModelsResponse = {
[number]: {
CurrentStartIndex: number,
TotalCount: number,
Results: {
[number]: {
Name: string,
AssetId: number,
AssetVersionId: number,
CreatorName: string,
},
},
},
}
local function toTitleCase(str: string): string
local function titleCase(first: string, rest: string): string
return first:upper() .. rest:lower()
end
local intermediate = string.gsub(str, "(%a)([%w_']*)", titleCase :: (string) -> string)
return intermediate:gsub("%s+", "")
end
local function collapseObjectsIntoContainer(objects: { Instance }): Instance?
local isPhysical = false
for _, object in objects do
if object:IsA("PVInstance") then
isPhysical = true
break
end
end
if isPhysical then
local model = Instance.new("Model")
for _, object in objects do
object.Parent = model
end
return model
end
if #objects > 1 then
local folder = Instance.new("Folder")
for _, object in objects do
object.Parent = folder
end
return folder
end
return objects[1]
end
local function loadAsset(assetId: number): Instance?
local objects = game:GetObjects(`rbxassetid://{assetId}`)
return collapseObjectsIntoContainer(objects)
end
local function getAssets(query: string): number?
local results: GetFreeModelsResponse = InsertService:GetFreeModels(query, 0)
local assets = {}
for i, result in results[1].Results do
if i > 6 then
break
end
table.insert(assets, result.AssetId)
end
return table.remove(assets, 1)
end
local function insertFromMarketplace(query: string): string
local primaryResult = getAssets(query)
if not primaryResult then
error("Failed to find asset")
end
local instance = loadAsset(primaryResult)
if not instance then
error("Failed to load asset")
end
local name = toTitleCase(query)
local i = 1
while workspace:FindFirstChild(name) do
name = query .. i
i += 1
end
instance.Name = name
instance.Parent = workspace
if instance:IsA("Model") then
instance:PivotTo(CFrame.new(getInsertPosition()))
end
return name
end
local function handleInsertModel(args: Args): string?
assert(type(args.query) == "string", "Missing query in insert_model")
return insertFromMarketplace(args.query)
end
return handleInsertModel

View file

@ -0,0 +1,132 @@
--!strict
--!nolint DeprecatedApi
local DeepCopy = require(script.Parent.Utils.DeepCopy)
type Args = {
command: string,
}
local function valueToString(value: any): string
local t = type(value)
if t == "number" then
if value ~= value then
return "NaN"
elseif value == math.huge then
return "Infinity"
elseif value == -math.huge then
return "-Infinity"
end
return tostring(value)
elseif t == "nil" then
return "null"
elseif t == "string" then
return string.format("%q", value)
elseif t == "boolean" then
return tostring(value)
elseif t == "table" then
local parts: { string } = {}
local isArray = #value > 0
if isArray then
for _, v in value :: { any } do
table.insert(parts, valueToString(v))
end
return "[" .. table.concat(parts, ", ") .. "]"
else
for k, v in value :: { [any]: any } do
table.insert(parts, valueToString(k) .. ": " .. valueToString(v))
end
return "{" .. table.concat(parts, ", ") .. "}"
end
end
return tostring(value)
end
local function runCodeWithOutput(command: string): string
local output = ""
local function serializeTable(arg: any): any
if typeof(arg) == "table" then
local hasTable = false
for key, _ in arg do
if typeof(key) ~= "number" then
hasTable = true
break
end
end
local newArg = {}
for key, value in arg do
local newKey = serializeTable(key)
newArg[if hasTable then tostring(newKey) else newKey] = serializeTable(value)
end
return newArg
elseif type(arg) == "userdata" then
return tostring(arg) or "UNABLE_TO_SERIALIZE"
end
return arg
end
local function toStrTable(t: { any }): { string }
local clonedTable = DeepCopy(t)
local strTable = table.create(#clonedTable)
for i, arg in clonedTable do
local serializedArg = serializeTable(arg)
strTable[i] = valueToString(serializedArg)
end
return strTable
end
local function addToOutput(header: string, ...)
local strResults = toStrTable(table.pack(...))
output ..= header .. " " .. table.concat(strResults, "\t") .. "\n"
end
local function executeCode(): { any }
local chunk = loadstring(command) :: any
local chunkfenv = getfenv(chunk)
local oldPrint = print
chunkfenv.print = function(...)
oldPrint(...)
addToOutput("[OUTPUT]", ...)
end
local oldWarn = warn
chunkfenv.warn = function(...)
oldWarn(...)
addToOutput("[WARNING]", ...)
end
local oldError = error
chunkfenv.error = function(...)
oldError(...)
addToOutput("[ERROR]", ...)
end
local results = table.pack(chunk())
if #results > 0 then
addToOutput("[RETURNED RESULTS]", table.unpack(results))
end
return results
end
local ok, errorMessage = pcall(executeCode)
if not ok then
addToOutput("[UNEXPECTED ERROR]", errorMessage)
end
return output
end
local ConsoleOutput = require(script.Parent.Utils.ConsoleOutput)
local function handleRunCode(args: Args): string?
assert(type(args.command) == "string", "Missing command in run_code")
ConsoleOutput.outputMessage = ""
return runCodeWithOutput(args.command)
end
return handleRunCode

View file

@ -0,0 +1,145 @@
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local StudioTestService = game:GetService("StudioTestService")
local ConsoleOutput = require(script.Parent.Utils.ConsoleOutput)
local StartStopPlay = require(script.Parent.StartStopPlay)
local StudioModeState = require(script.Parent.Utils.StudioModeState)
type Args = {
code: string,
timeout: number?,
mode: StudioModeState.TestMode,
}
local TEST_SCRIPT_NAME = "AtlasTestScript"
local function buildTestRunnerSource(userCode: string, timeout: number): string
local timeoutStr = tostring(timeout)
return [[
local StudioTestService = game:GetService("StudioTestService")
local LogService = game:GetService("LogService")
local RunService = game:GetService("RunService")
if not RunService:IsRunning() then
return
end
local capturedLogs = {}
local logConnection = nil
local MESSAGE_TYPE_MAP = {
[Enum.MessageType.MessageOutput] = "output",
[Enum.MessageType.MessageInfo] = "info",
[Enum.MessageType.MessageWarning] = "warning",
[Enum.MessageType.MessageError] = "error",
}
logConnection = LogService.MessageOut:Connect(function(message, messageType)
if string.sub(message, 1, 7) == "[Atlas]" then return end
table.insert(capturedLogs, {
level = MESSAGE_TYPE_MAP[messageType] or "output",
message = message,
ts = os.clock(),
})
end)
local startTime = os.clock()
local isTimeout = false
local function endTest()
local duration = os.clock() - startTime
task.wait(0.1)
if logConnection then logConnection:Disconnect() end
local errors = {}
for _, log in ipairs(capturedLogs) do
if log.level == "error" or log.level == "warning" then
table.insert(errors, log)
end
end
StudioTestService:EndTest({
success = ok,
value = if ok then tostring(result) else nil,
error = if not ok then tostring(result) else nil,
logs = capturedLogs,
errors = errors,
duration = duration,
isTimeout = isTimeout,
})
end
task.spawn(function()
task.wait(]] .. timeoutStr .. [[)
isTimeout = true
endTest()
end)
local ok, result = pcall(function()
]] .. userCode .. [[
end)
endTest()
]]
end
local function removeTestScript()
local testScript = ServerScriptService:FindFirstChild(TEST_SCRIPT_NAME)
if testScript then
testScript:Destroy()
end
end
local function injectTestScript(args: Args)
removeTestScript()
local source = buildTestRunnerSource(args.code, args.timeout or 1_000_000)
local testScript = Instance.new("Script")
testScript.Name = TEST_SCRIPT_NAME
testScript.Parent = ServerScriptService
testScript.Source = source
end
local function runScriptInPlayMode(args: Args): string?
injectTestScript(args)
ConsoleOutput.outputMessage = ""
local success, result = pcall(function()
if args.mode == "start_play" then
StudioModeState.studioMode = "start_play"
return StudioTestService:ExecutePlayModeAsync({})
else
StudioModeState.studioMode = "run_server"
return StudioTestService:ExecuteRunModeAsync({})
end
end)
StudioModeState.studioMode = "stop"
removeTestScript()
if not success then
error(`Failed to run script in play mode: {result}`)
end
return result
end
local function handleRunScriptInPlayMode(args: Args): string?
assert(type(args.code) == "string" and args.code ~= "", "Missing code in run_script_in_play_mode")
assert(
args.timeout == nil or (type(args.timeout) == "number" and args.timeout >= 0),
"Timeout must be a number >= 0"
)
StartStopPlay({ mode = "stop" })
task.wait(0.2)
return runScriptInPlayMode(args)
end
return handleRunScriptInPlayMode

View file

@ -0,0 +1,88 @@
--!strict
local StudioTestService = game:GetService("StudioTestService")
local ConsoleOutput = require(script.Parent.Utils.ConsoleOutput)
local GameStopUtil = require(script.Parent.Utils.GameStopUtil)
local StudioModeState = require(script.Parent.Utils.StudioModeState)
type Args = {
mode: StudioModeState.StartStopPlayMode,
}
local function callWithTimeout(callback: () -> any, timeout: number, timeoutResult: string): (string?, boolean)
local BindableEvent = Instance.new("BindableEvent")
local isDone = false
local _success = true
local result = timeoutResult
local isTimeout = false
task.spawn(function()
_success, result = pcall(function()
return callback()
end)
if not isDone then
isDone = true
BindableEvent:Fire()
end
end)
task.spawn(function()
task.wait(timeout)
if not isDone then
isDone = true
isTimeout = true
BindableEvent:Fire()
end
end)
if not isDone then
BindableEvent.Event:Wait()
end
return result, isTimeout
end
local function startPlay(): string?
ConsoleOutput.outputMessage = ""
StudioModeState.studioMode = "start_play"
local result, isTimeout = callWithTimeout(function()
return StudioTestService:ExecutePlayModeAsync({})
end, 0.1, "Started play")
if not isTimeout then
return "Already in play mode"
end
return result
end
local function stop(): string?
GameStopUtil.stopPlay()
task.wait(1)
StudioModeState.studioMode = "stop"
return "Stopped"
end
local function runServer(): string?
StudioModeState.studioMode = "run_server"
local result, isTimeout = callWithTimeout(function()
return StudioTestService:ExecuteRunModeAsync({})
end, 0.1, "Ran server")
if not isTimeout then
return "Already in run_server mode"
end
return result
end
local function handleStartStopPlay(args: Args): string?
assert(type(args.mode) == "string", "Missing mode in start_stop_play")
if args.mode == "start_play" then
return startPlay()
elseif args.mode == "stop" then
return stop()
elseif args.mode == "run_server" then
return runServer()
end
error("Invalid mode in start_stop_play, must be start_play, stop, or run_server")
end
return handleStartStopPlay

View file

@ -0,0 +1,24 @@
--!strict
local LogService = game:GetService("LogService")
local MAX_OUTPUT = 10000
local ConsoleOutput = {
outputMessage = "",
}
function ConsoleOutput.startListener(): RBXScriptConnection
local conn = LogService.MessageOut:Connect(function(message: string, _: Enum.MessageType)
if #ConsoleOutput.outputMessage + #message <= MAX_OUTPUT then
if #ConsoleOutput.outputMessage == 0 then
ConsoleOutput.outputMessage = message
else
ConsoleOutput.outputMessage = ConsoleOutput.outputMessage .. "\n" .. message
end
end
end)
return conn
end
return ConsoleOutput

View file

@ -0,0 +1,31 @@
--!strict
--!optimize 2
function DeepCopy<T>(value: T, cache_: { [any]: any }?): T
if type(value) ~= "table" then
return value
end
local cache: { [any]: any } = cache_ or {}
local cachedResult = cache[value]
if cachedResult then
return cachedResult
end
local result = {}
cache[value] = result
for k, v in next, value do
result[DeepCopy(k, cache)] = DeepCopy(v, cache)
end
local valueMT = getmetatable(value)
if valueMT then
setmetatable(result, valueMT :: any)
end
return result :: any
end
return DeepCopy

View file

@ -0,0 +1,32 @@
--!strict
local StudioTestService = game:GetService("StudioTestService")
local MCP_STOP_PLAY_KEY = "Atlas_MCP_STOP_PLAY"
local GameStopUtil = {}
local _plugin: Plugin? = nil
function GameStopUtil.setPlugin(p: Plugin)
_plugin = p
end
function GameStopUtil.stopPlay()
assert(_plugin, "GameStopUtil: plugin not set")
_plugin:SetSetting(MCP_STOP_PLAY_KEY, true)
end
function GameStopUtil.monitorForStopPlay()
assert(_plugin, "GameStopUtil: plugin not set")
_plugin:SetSetting(MCP_STOP_PLAY_KEY, false)
while true do
if _plugin:GetSetting(MCP_STOP_PLAY_KEY) then
_plugin:SetSetting(MCP_STOP_PLAY_KEY, false)
StudioTestService:EndTest({})
end
task.wait(1)
end
end
return GameStopUtil

View file

@ -0,0 +1,8 @@
--!strict
export type TestMode = "start_play" | "run_server"
export type StartStopPlayMode = TestMode | "stop"
return {
studioMode = "stop" :: StartStopPlayMode,
}

View file

@ -0,0 +1,26 @@
--!strict
local strict = require(script.Parent.strict)
local tools: { [string]: (any) -> string? } = {
run_code = require(script.RunCode),
insert_model = require(script.InsertModel),
get_console_output = require(script.GetConsoleOutput),
get_studio_mode = require(script.GetStudioMode),
start_stop_play = require(script.StartStopPlay),
run_script_in_play_mode = require(script.RunScriptInPlayMode),
}
local McpTools = {}
function McpTools.dispatch(toolType: string, args: any): string?
local handler = tools[toolType]
if not handler then
error("Unknown MCP tool: " .. tostring(toolType))
end
return handler(args)
end
return strict("McpTools", {
dispatch = McpTools.dispatch,
})

View file

@ -2,6 +2,8 @@ if not plugin then
return return
end end
local RunService = game:GetService("RunService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages local Packages = Rojo.Packages
@ -12,10 +14,21 @@ local Settings = require(script.Settings)
local Config = require(script.Config) local Config = require(script.Config)
local App = require(script.App) local App = require(script.App)
local ConsoleOutput = require(script.McpTools.Utils.ConsoleOutput)
local GameStopUtil = require(script.McpTools.Utils.GameStopUtil)
GameStopUtil.setPlugin(plugin)
Log.setLogLevelThunk(function() Log.setLogLevelThunk(function()
return Log.Level[Settings:get("logLevel")] or Log.Level.Info return Log.Level[Settings:get("logLevel")] or Log.Level.Info
end) end)
if RunService:IsRunning() and RunService:IsServer() then
task.spawn(GameStopUtil.monitorForStopPlay)
end
local consoleOutputConn = ConsoleOutput.startListener()
local app = Roact.createElement(App, { local app = Roact.createElement(App, {
plugin = plugin, plugin = plugin,
}) })
@ -23,6 +36,7 @@ local tree = Roact.mount(app, game:GetService("CoreGui"), "Atlas UI")
plugin.Unloading:Connect(function() plugin.Unloading:Connect(function()
Roact.unmount(tree) Roact.unmount(tree)
consoleOutputConn:Disconnect()
end) end)
if Config.isDevBuild then if Config.isDevBuild then

View file

@ -84,6 +84,16 @@ pub struct McpSyncResult {
pub message: Option<String>, pub message: Option<String>,
} }
/// Result for passthrough plugin tools (run_code, insert_model, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginToolResult {
pub request_id: String,
pub status: String,
#[serde(default)]
pub response: Option<String>,
}
/// Result for get_script, deserialized from the plugin's Value response. /// Result for get_script, deserialized from the plugin's Value response.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -234,7 +244,10 @@ pub async fn call(
}; };
match rpc_request.method.as_str() { match rpc_request.method.as_str() {
"initialize" => handle_initialize(rpc_request.id), "initialize" => {
log::info!("MCP agent connected");
handle_initialize(rpc_request.id)
}
"notifications/initialized" => Response::builder() "notifications/initialized" => Response::builder()
.status(StatusCode::ACCEPTED) .status(StatusCode::ACCEPTED)
.body(Full::new(Bytes::new())) .body(Full::new(Bytes::new()))
@ -286,7 +299,7 @@ fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
"tools": [ "tools": [
{ {
"name": "atlas_sync", "name": "atlas_sync",
"description": "Sync filesystem changes to Roblox Studio. Supports modes: 'standard' (default, auto-accepts if all git-resolved, else shows UI), 'manual' (always shows UI), 'fastfail' (fails immediately if unresolved changes exist), 'dryrun' (returns what would change without applying). Supports overrides to auto-accept specific changes by instance id with hash/property verification.", "description": include_str!("mcp_docs/atlas_sync.md"),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -327,7 +340,7 @@ fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
}, },
{ {
"name": "get_script", "name": "get_script",
"description": "Read a script's source code from Roblox Studio. The id and fsPath parameters come from a previous atlas_sync response's change entries.\n\nIf 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.\n\nConflict resolution workflow:\n1. atlas_sync(mode: \"fastfail\") -- get changes with studioHash, fail if unresolved\n2. get_script(id: \"...\") -- read the Studio version of a conflicting script\n3. Merge the Studio source with your local changes, write to filesystem\n4. atlas_sync(overrides: [{id, direction: \"push\", studioHash: \"...from get_script response...\"}])\n\nThe 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.", "description": include_str!("mcp_docs/get_script.md"),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -345,6 +358,88 @@ fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
} }
} }
} }
},
{
"name": "run_code",
"description": include_str!("mcp_docs/run_code.md"),
"inputSchema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Luau code to execute in Roblox Studio."
}
},
"required": ["command"]
}
},
{
"name": "insert_model",
"description": include_str!("mcp_docs/insert_model.md"),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query for the model on the Roblox Creator Store."
}
},
"required": ["query"]
}
},
{
"name": "get_console_output",
"description": include_str!("mcp_docs/get_console_output.md"),
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "get_studio_mode",
"description": include_str!("mcp_docs/get_studio_mode.md"),
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "start_stop_play",
"description": include_str!("mcp_docs/start_stop_play.md"),
"inputSchema": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["start_play", "run_server", "stop"],
"description": "Play mode action."
}
},
"required": ["mode"]
}
},
{
"name": "run_script_in_play_mode",
"description": include_str!("mcp_docs/run_script_in_play_mode.md"),
"inputSchema": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Luau code to run inside a play session."
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds. Defaults to 100."
},
"mode": {
"type": "string",
"enum": ["start_play", "run_server"],
"description": "Play mode to start."
}
},
"required": ["code", "mode"]
}
} }
] ]
}); });
@ -370,9 +465,23 @@ async fn handle_tools_call(
.cloned() .cloned()
.unwrap_or(Value::Object(Default::default())); .unwrap_or(Value::Object(Default::default()));
log::info!(
"MCP call: {} {}",
tool_name,
serde_json::to_string(&arguments).unwrap_or_default()
);
match tool_name { match tool_name {
"atlas_sync" => handle_atlas_sync(id, arguments, mcp_state, active_api_connections).await, "atlas_sync" => handle_atlas_sync(id, arguments, mcp_state, active_api_connections).await,
"get_script" => handle_get_script(id, arguments, mcp_state).await, "get_script" => handle_get_script(id, arguments, mcp_state).await,
"run_code"
| "insert_model"
| "get_console_output"
| "get_studio_mode"
| "start_stop_play"
| "run_script_in_play_mode" => {
dispatch_to_plugin(id, tool_name, arguments, mcp_state).await
}
_ => { _ => {
let resp = JsonRpcResponse::error(id, -32602, format!("Unknown tool: {tool_name}")); let resp = JsonRpcResponse::error(id, -32602, format!("Unknown tool: {tool_name}"));
json_response(&resp, StatusCode::OK) json_response(&resp, StatusCode::OK)
@ -495,6 +604,12 @@ async fn handle_atlas_sync(
let _ = mcp_state.command_tx.send(None); let _ = mcp_state.command_tx.send(None);
log::info!(
"MCP result: atlas_sync status={} changes={}",
result.status,
result.changes.len()
);
let is_error = !matches!(result.status.as_str(), "success" | "empty" | "dryrun"); let is_error = !matches!(result.status.as_str(), "success" | "empty" | "dryrun");
let mut text = match result.status.as_str() { let mut text = match result.status.as_str() {
@ -649,6 +764,12 @@ async fn handle_get_script(
let is_error = result.status != "success"; let is_error = result.status != "success";
log::info!(
"MCP result: get_script status={} class={}",
result.status,
result.class_name.as_deref().unwrap_or("?")
);
if is_error { if is_error {
let msg = result.message.as_deref().unwrap_or("Unknown error"); let msg = result.message.as_deref().unwrap_or("Unknown error");
return tool_response(id, true, msg); return tool_response(id, true, msg);
@ -670,6 +791,96 @@ async fn handle_get_script(
tool_response(id, false, &text) tool_response(id, false, &text)
} }
async fn dispatch_to_plugin(
id: Option<Value>,
tool_name: &str,
arguments: Value,
mcp_state: Arc<McpState>,
) -> Response<Full<Bytes>> {
if mcp_state
.command_in_progress
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return tool_response(
id,
true,
"An MCP command is already in progress. Please wait for it to complete.",
);
}
if !mcp_state.plugin_stream_connected.load(Ordering::Relaxed) {
mcp_state.command_in_progress.store(false, Ordering::SeqCst);
return tool_response(
id,
true,
"No Roblox Studio plugin is connected to the MCP stream. \
Make sure Studio is open with the Atlas plugin installed.",
);
}
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
{
let mut slot = mcp_state
.result_tx
.lock()
.unwrap_or_else(|e| e.into_inner());
*slot = Some(result_tx);
}
let request_id = uuid::Uuid::new_v4().to_string();
let command = serde_json::json!({
"type": tool_name,
"requestId": request_id,
"args": arguments,
});
if mcp_state.command_tx.send(Some(command)).is_err() {
mcp_state.command_in_progress.store(false, Ordering::SeqCst);
return tool_response(
id,
true,
&format!("Failed to send {tool_name} command to the plugin."),
);
}
let result_value = match result_rx.await {
Ok(v) => v,
Err(_) => {
mcp_state.command_in_progress.store(false, Ordering::SeqCst);
return tool_response(
id,
true,
&format!("Plugin disconnected before completing the {tool_name} operation."),
);
}
};
mcp_state.command_in_progress.store(false, Ordering::SeqCst);
let _ = mcp_state.command_tx.send(None);
let result: PluginToolResult = match serde_json::from_value(result_value) {
Ok(r) => r,
Err(e) => {
return tool_response(
id,
true,
&format!("Failed to parse {tool_name} result from plugin: {e}"),
);
}
};
let is_error = result.status != "success";
let text = result
.response
.as_deref()
.unwrap_or(if is_error { "Unknown error" } else { "" });
log::info!("MCP result: {} {}", tool_name, &text[..text.len().min(200)]);
tool_response(id, is_error, text)
}
fn tool_response(id: Option<Value>, is_error: bool, text: &str) -> Response<Full<Bytes>> { fn tool_response(id: Option<Value>, is_error: bool, text: &str) -> Response<Full<Bytes>> {
let result = serde_json::json!({ let result = serde_json::json!({
"content": [{ "type": "text", "text": text }], "content": [{ "type": "text", "text": text }],
@ -790,6 +1001,43 @@ mod tests {
assert_eq!(json["oneShotSync"], false); assert_eq!(json["oneShotSync"], false);
assert_eq!(json["confirmationBehavior"], "Always"); assert_eq!(json["confirmationBehavior"], "Always");
} }
#[test]
fn plugin_tool_result_deserializes() {
let json = serde_json::json!({
"requestId": "r1",
"status": "success",
"response": "hello world"
});
let result: PluginToolResult = serde_json::from_value(json).unwrap();
assert_eq!(result.request_id, "r1");
assert_eq!(result.status, "success");
assert_eq!(result.response.as_deref(), Some("hello world"));
}
#[test]
fn plugin_tool_result_defaults_for_missing_response() {
let json = serde_json::json!({
"requestId": "r2",
"status": "error"
});
let result: PluginToolResult = serde_json::from_value(json).unwrap();
assert_eq!(result.status, "error");
assert!(result.response.is_none());
}
#[test]
fn plugin_tool_result_serializes_camel_case() {
let result = PluginToolResult {
request_id: "r3".to_string(),
status: "success".to_string(),
response: Some("output".to_string()),
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["requestId"], "r3");
assert_eq!(json["status"], "success");
assert_eq!(json["response"], "output");
}
} }
// -- McpState tests ------------------------------------------------------- // -- McpState tests -------------------------------------------------------
@ -890,17 +1138,32 @@ mod tests {
} }
#[test] #[test]
fn tools_list_returns_atlas_sync() { fn tools_list_returns_all_tools() {
let resp = handle_tools_list(Some(Value::from(2))); let resp = handle_tools_list(Some(Value::from(2)));
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() }); let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() });
let json: Value = serde_json::from_slice(&bytes).unwrap(); let json: Value = serde_json::from_slice(&bytes).unwrap();
let tools = json["result"]["tools"].as_array().unwrap(); let tools = json["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2); assert_eq!(tools.len(), 8);
assert_eq!(tools[0]["name"], "atlas_sync"); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(tools[0]["inputSchema"].is_object()); assert_eq!(
assert_eq!(tools[1]["name"], "get_script"); names,
vec![
"atlas_sync",
"get_script",
"run_code",
"insert_model",
"get_console_output",
"get_studio_mode",
"start_stop_play",
"run_script_in_play_mode",
]
);
for tool in tools {
assert!(tool["inputSchema"].is_object());
assert!(tool["description"].as_str().unwrap().len() > 10);
}
} }
#[test] #[test]
@ -1620,7 +1883,7 @@ mod tests {
let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() }); let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() });
let json: Value = serde_json::from_slice(&bytes).unwrap(); let json: Value = serde_json::from_slice(&bytes).unwrap();
let tools = json["result"]["tools"].as_array().unwrap(); let tools = json["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2); assert_eq!(tools.len(), 8);
let get_script = tools.iter().find(|t| t["name"] == "get_script").unwrap(); let get_script = tools.iter().find(|t| t["name"] == "get_script").unwrap();
assert!(get_script["description"] assert!(get_script["description"]
@ -1755,4 +2018,387 @@ mod tests {
assert!(text.contains("Instance not found by id")); assert!(text.contains("Instance not found by id"));
} }
} }
// -- dispatch_to_plugin guard tests ----------------------------------------
mod dispatch_guards {
use super::*;
#[tokio::test]
async fn rejects_when_command_in_progress() {
let state = Arc::new(McpState::new());
state.command_in_progress.store(true, Ordering::SeqCst);
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"run_code",
serde_json::json!({"command": "print(1)"}),
state,
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], true);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("already in progress"));
}
#[tokio::test]
async fn rejects_when_no_plugin_connected() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(false, Ordering::SeqCst);
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"get_studio_mode",
serde_json::json!({}),
state.clone(),
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], true);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("No Roblox Studio plugin"));
assert!(!state.command_in_progress.load(Ordering::Relaxed));
}
#[tokio::test]
async fn sends_command_and_returns_success() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
let cmd = rx.borrow().clone().unwrap();
let req_id = cmd["requestId"].as_str().unwrap_or("");
assert_eq!(cmd["type"], "run_code");
assert_eq!(cmd["args"]["command"], "print(42)");
let result = serde_json::json!({
"requestId": req_id,
"status": "success",
"response": "[OUTPUT] 42\n",
});
let tx = state2.result_tx.lock().unwrap().take().unwrap();
tx.send(result).unwrap();
});
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"run_code",
serde_json::json!({"command": "print(42)"}),
state.clone(),
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], false);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("[OUTPUT] 42"));
assert!(!state.command_in_progress.load(Ordering::Relaxed));
}
#[tokio::test]
async fn returns_error_status_from_plugin() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
let cmd = rx.borrow().clone().unwrap();
let req_id = cmd["requestId"].as_str().unwrap_or("");
let result = serde_json::json!({
"requestId": req_id,
"status": "error",
"response": "Missing command in run_code",
});
let tx = state2.result_tx.lock().unwrap().take().unwrap();
tx.send(result).unwrap();
});
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"run_code",
serde_json::json!({}),
state,
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], true);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("Missing command"));
}
#[tokio::test]
async fn handles_plugin_disconnect() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
// Drop the result_tx without sending to simulate disconnect.
let _ = state2.result_tx.lock().unwrap().take();
});
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"get_console_output",
serde_json::json!({}),
state.clone(),
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], true);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("disconnected"));
assert!(!state.command_in_progress.load(Ordering::Relaxed));
}
#[tokio::test]
async fn empty_response_returns_empty_text() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
let cmd = rx.borrow().clone().unwrap();
let req_id = cmd["requestId"].as_str().unwrap_or("");
let result = serde_json::json!({
"requestId": req_id,
"status": "success",
});
let tx = state2.result_tx.lock().unwrap().take().unwrap();
tx.send(result).unwrap();
});
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"get_console_output",
serde_json::json!({}),
state,
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], false);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert_eq!(text, "");
}
#[tokio::test]
async fn malformed_result_returns_parse_error() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
let result = serde_json::json!("just a string, not an object");
let tx = state2.result_tx.lock().unwrap().take().unwrap();
tx.send(result).unwrap();
});
let resp = dispatch_to_plugin(
Some(Value::from(1)),
"run_code",
serde_json::json!({"command": "x"}),
state.clone(),
)
.await;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["result"]["isError"], true);
let text = json["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("Failed to parse"));
assert!(text.contains("run_code"));
assert!(!state.command_in_progress.load(Ordering::Relaxed));
}
#[tokio::test]
async fn command_channel_cleared_after_completion() {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
let cmd = rx.borrow().clone().unwrap();
let req_id = cmd["requestId"].as_str().unwrap_or("");
let result = serde_json::json!({
"requestId": req_id,
"status": "success",
"response": "ok",
});
let tx = state2.result_tx.lock().unwrap().take().unwrap();
tx.send(result).unwrap();
});
let _ = dispatch_to_plugin(
Some(Value::from(1)),
"get_studio_mode",
serde_json::json!({}),
state.clone(),
)
.await;
assert!(
state.command_rx.borrow().is_none(),
"command_tx should be reset to None after dispatch"
);
}
#[tokio::test]
async fn tool_type_forwarded_in_command() {
for tool_name in &[
"insert_model",
"get_console_output",
"get_studio_mode",
"start_stop_play",
"run_script_in_play_mode",
] {
let state = Arc::new(McpState::new());
state.plugin_stream_connected.store(true, Ordering::SeqCst);
let expected_type = tool_name.to_string();
let state2 = Arc::clone(&state);
tokio::spawn(async move {
let mut rx = state2.command_rx.clone();
rx.changed().await.unwrap();
let cmd = rx.borrow().clone().unwrap();
assert_eq!(
cmd["type"].as_str().unwrap(),
expected_type,
"type field should match tool_name for {expected_type}"
);
let req_id = cmd["requestId"].as_str().unwrap_or("");
let result = serde_json::json!({
"requestId": req_id,
"status": "success",
"response": "ok",
});
let tx = state2.result_tx.lock().unwrap().take().unwrap();
tx.send(result).unwrap();
});
let _ = dispatch_to_plugin(
Some(Value::from(1)),
tool_name,
serde_json::json!({}),
state,
)
.await;
}
}
}
// -- Tool schema validation ------------------------------------------------
mod tool_schema_tests {
use super::*;
fn get_tools() -> Vec<Value> {
let resp = handle_tools_list(Some(Value::from(1)));
let rt = tokio::runtime::Runtime::new().unwrap();
let bytes = rt.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() });
let json: Value = serde_json::from_slice(&bytes).unwrap();
json["result"]["tools"].as_array().unwrap().clone()
}
fn find_tool<'a>(tools: &'a [Value], name: &str) -> &'a Value {
tools.iter().find(|t| t["name"] == name).unwrap()
}
#[test]
fn run_code_requires_command() {
let tools = get_tools();
let tool = find_tool(&tools, "run_code");
let required = tool["inputSchema"]["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "command"));
assert!(tool["inputSchema"]["properties"]["command"]["type"] == "string");
}
#[test]
fn insert_model_requires_query() {
let tools = get_tools();
let tool = find_tool(&tools, "insert_model");
let required = tool["inputSchema"]["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "query"));
assert!(tool["inputSchema"]["properties"]["query"]["type"] == "string");
}
#[test]
fn get_console_output_has_no_required() {
let tools = get_tools();
let tool = find_tool(&tools, "get_console_output");
assert!(tool["inputSchema"]["required"].is_null());
}
#[test]
fn get_studio_mode_has_no_required() {
let tools = get_tools();
let tool = find_tool(&tools, "get_studio_mode");
assert!(tool["inputSchema"]["required"].is_null());
}
#[test]
fn start_stop_play_requires_mode_with_enum() {
let tools = get_tools();
let tool = find_tool(&tools, "start_stop_play");
let required = tool["inputSchema"]["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "mode"));
let mode_enum = tool["inputSchema"]["properties"]["mode"]["enum"]
.as_array()
.unwrap();
let values: Vec<&str> = mode_enum.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(values, vec!["start_play", "run_server", "stop"]);
}
#[test]
fn run_script_in_play_mode_requires_code_and_mode() {
let tools = get_tools();
let tool = find_tool(&tools, "run_script_in_play_mode");
let required = tool["inputSchema"]["required"].as_array().unwrap();
let req_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(req_strs.contains(&"code"));
assert!(req_strs.contains(&"mode"));
assert!(!req_strs.contains(&"timeout"));
let mode_enum = tool["inputSchema"]["properties"]["mode"]["enum"]
.as_array()
.unwrap();
let values: Vec<&str> = mode_enum.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(values, vec!["start_play", "run_server"]);
assert!(tool["inputSchema"]["properties"]["timeout"]["type"] == "integer");
}
}
} }

View file

@ -0,0 +1,21 @@
Sync filesystem changes to Roblox Studio.
Modes:
- `standard` (default): auto-accepts if all changes are git-resolved, otherwise shows Studio UI for manual review.
- `manual`: always shows Studio UI, never auto-accepts.
- `fastfail`: fails immediately with `fastfail_unresolved` status if any changes are unresolved after defaults and overrides. Use this to probe for conflicts before committing to a sync.
- `dryrun`: returns what would change without applying anything. Useful for exploring the current diff or establishing the instance id mapping for `get_script`.
Overrides let you auto-accept specific changes by instance id with optional verification:
- `id` (required): the 32-char hex server Ref from a previous sync response.
- `direction` (required): `"push"` (Atlas to Studio) or `"pull"` (Studio to Atlas).
- `studioHash` (optional, scripts only): SHA1 of the git blob format of the script's current Studio Source. Override is rejected if the hash doesn't match (concurrent edit detected).
- `expectedProperties` (optional): map of property name to expected current Studio value in RbxDom encoded format. Override is rejected if any value doesn't match.
Recommended workflow:
1. `atlas_sync()` — basic sync. Auto-accepts if everything is git-resolved, else shows Studio UI.
2. `atlas_sync(mode: "dryrun")` — explore what would change, get instance ids and fsPath mappings.
3. `atlas_sync(mode: "fastfail")` — get enriched change data (studioHash, properties, fsPath). Fails immediately if unresolved.
4. On conflict: call `get_script(id)` to read the Studio version, merge with your local changes, write to filesystem, then `atlas_sync(overrides: [{id, direction: "push", studioHash: "...from get_script..."}])`.
Response includes a human-readable summary followed by a `<json>` block with structured data. Each change entry contains: path, id, direction, patchType (Add/Edit/Remove), className, studioHash (scripts only), defaultSelection, fsPath (Atlas filesystem path), and properties (current vs incoming values in RbxDom encoded format, Source omitted for scripts).

View file

@ -0,0 +1,3 @@
Get the console output from Roblox Studio.
Returns the accumulated output messages since the last `run_code` or `run_script_in_play_mode` call.

View file

@ -0,0 +1,15 @@
Read a script's source code from Roblox Studio.
Parameters `id` and `fsPath` come from a previous `atlas_sync` response's change entries. At least one is required; `id` takes priority.
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 per script, fail if unresolved conflicts exist.
2. `get_script(id: "...")` — read the Studio version of a conflicting script.
3. Merge the Studio source with your local changes, write the merged result to the filesystem.
4. `atlas_sync(overrides: [{id, direction: "push", studioHash: "...from get_script response..."}])` — retry sync, auto-accepting your merged version. The studioHash proves the script hasn't changed between your read and the retry.
The `studioHash` in the response is the SHA1 of the git blob format (`blob <len>\0<content>`) of the script's Source property. Pass it as the `studioHash` in sync overrides to verify the script hasn't been edited by someone else between your `get_script` call and the retry sync.
Set `fromDraft: true` to read the unsaved editor content (via ScriptEditorService) instead of the saved Source property.

View file

@ -0,0 +1,3 @@
Get the current Roblox Studio play mode.
Returns one of: `start_play`, `run_server`, or `stop`.

View file

@ -0,0 +1,8 @@
Insert a model from the Roblox Creator Store into the workspace.
Searches free models by query and inserts the top result.
The model is placed at the center of the camera viewport via raycast.
Returns the inserted model name.
If it returns "Failed to find asset", try a different or broader search query.
If it returns "Failed to load asset", the asset may be restricted or unavailable.

View file

@ -0,0 +1,8 @@
Run a Luau command in Roblox Studio and return the printed output.
Can be used to both make changes and retrieve information.
The code is executed via `loadstring` in the Studio command bar context.
Output from `print()`, `warn()`, and `error()` is captured and returned.
Return values from the code chunk are also included in the output.
The code has full Studio API access — you can read/write properties, create/destroy instances, call services, etc.

View file

@ -0,0 +1,12 @@
Run a script in Roblox Studio play mode and automatically stop play after the script finishes or times out.
Returns structured JSON output:
```
{ success: boolean, value: string, error: string, logs: [{ level, message, ts }], errors: [{ level, message, ts }], duration: number, isTimeout: boolean }
```
Starting play mode will temporarily disconnect the Atlas plugin. The plugin reconnects automatically after play stops.
Prefer using `start_stop_play` when you need manual control over the play session.
If it returns "Previous call to start play session has not been completed", call `start_stop_play` with mode `stop` first, then try again.

View file

@ -0,0 +1,10 @@
Start or stop play mode in Roblox Studio.
Modes:
- `start_play` — enters play testing.
- `run_server` — starts a server without a client. Don't use unless you are sure no client/player is needed.
- `stop` — exits any active play session.
Starting play mode will temporarily disconnect the Atlas plugin. The plugin reconnects automatically when play mode stops.
If it returns "Previous call to start play session has not been completed", call with `stop` first, then retry the original mode.