mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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:
parent
fd49602adb
commit
fd990b86c6
24 changed files with 1464 additions and 12 deletions
22
TODO.txt
22
TODO.txt
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
10
plugin/src/McpTools/GetConsoleOutput.luau
Normal file
10
plugin/src/McpTools/GetConsoleOutput.luau
Normal 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
|
||||||
9
plugin/src/McpTools/GetStudioMode.luau
Normal file
9
plugin/src/McpTools/GetStudioMode.luau
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
--!strict
|
||||||
|
|
||||||
|
local StudioModeState = require(script.Parent.Utils.StudioModeState)
|
||||||
|
|
||||||
|
local function handleGetStudioMode(): string?
|
||||||
|
return StudioModeState.studioMode
|
||||||
|
end
|
||||||
|
|
||||||
|
return handleGetStudioMode
|
||||||
135
plugin/src/McpTools/InsertModel.luau
Normal file
135
plugin/src/McpTools/InsertModel.luau
Normal 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
|
||||||
132
plugin/src/McpTools/RunCode.luau
Normal file
132
plugin/src/McpTools/RunCode.luau
Normal 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
|
||||||
145
plugin/src/McpTools/RunScriptInPlayMode.luau
Normal file
145
plugin/src/McpTools/RunScriptInPlayMode.luau
Normal 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
|
||||||
88
plugin/src/McpTools/StartStopPlay.luau
Normal file
88
plugin/src/McpTools/StartStopPlay.luau
Normal 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
|
||||||
24
plugin/src/McpTools/Utils/ConsoleOutput.luau
Normal file
24
plugin/src/McpTools/Utils/ConsoleOutput.luau
Normal 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
|
||||||
31
plugin/src/McpTools/Utils/DeepCopy.luau
Normal file
31
plugin/src/McpTools/Utils/DeepCopy.luau
Normal 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
|
||||||
32
plugin/src/McpTools/Utils/GameStopUtil.luau
Normal file
32
plugin/src/McpTools/Utils/GameStopUtil.luau
Normal 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
|
||||||
8
plugin/src/McpTools/Utils/StudioModeState.luau
Normal file
8
plugin/src/McpTools/Utils/StudioModeState.luau
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
--!strict
|
||||||
|
|
||||||
|
export type TestMode = "start_play" | "run_server"
|
||||||
|
export type StartStopPlayMode = TestMode | "stop"
|
||||||
|
|
||||||
|
return {
|
||||||
|
studioMode = "stop" :: StartStopPlayMode,
|
||||||
|
}
|
||||||
26
plugin/src/McpTools/init.luau
Normal file
26
plugin/src/McpTools/init.luau
Normal 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,
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
664
src/web/mcp.rs
664
src/web/mcp.rs
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/web/mcp_docs/atlas_sync.md
Normal file
21
src/web/mcp_docs/atlas_sync.md
Normal 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).
|
||||||
3
src/web/mcp_docs/get_console_output.md
Normal file
3
src/web/mcp_docs/get_console_output.md
Normal 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.
|
||||||
15
src/web/mcp_docs/get_script.md
Normal file
15
src/web/mcp_docs/get_script.md
Normal 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.
|
||||||
3
src/web/mcp_docs/get_studio_mode.md
Normal file
3
src/web/mcp_docs/get_studio_mode.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Get the current Roblox Studio play mode.
|
||||||
|
|
||||||
|
Returns one of: `start_play`, `run_server`, or `stop`.
|
||||||
8
src/web/mcp_docs/insert_model.md
Normal file
8
src/web/mcp_docs/insert_model.md
Normal 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.
|
||||||
8
src/web/mcp_docs/run_code.md
Normal file
8
src/web/mcp_docs/run_code.md
Normal 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.
|
||||||
12
src/web/mcp_docs/run_script_in_play_mode.md
Normal file
12
src/web/mcp_docs/run_script_in_play_mode.md
Normal 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.
|
||||||
10
src/web/mcp_docs/start_stop_play.md
Normal file
10
src/web/mcp_docs/start_stop_play.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue