mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-26 10:34:07 +00:00
Implement MCP integration for AI agents and enhance sync functionality
- Added a new section in the README to document the integration of MCP (Model Context Protocol) for AI agents, detailing how to set up and use the `atlas_sync` tool. - Introduced `McpStream` to manage WebSocket connections for real-time sync commands between the plugin and the MCP server. - Enhanced the `App` component to initialize and manage the MCP stream, including handling sync commands and user confirmations. - Updated the API to support WebSocket upgrades for the MCP stream, allowing for efficient communication with connected plugins. - Improved the handling of sync commands, ensuring proper responses based on the current connection state and user selections. These changes aim to facilitate programmatic syncs to Roblox Studio, improving the workflow for teams using AI agents.
This commit is contained in:
parent
0a359ccaf1
commit
0f5cabb22a
8 changed files with 1810 additions and 18 deletions
|
|
@ -37,6 +37,7 @@ local strict = require(Plugin.strict)
|
|||
local Dictionary = require(Plugin.Dictionary)
|
||||
local ServeSession = require(Plugin.ServeSession)
|
||||
local ApiContext = require(Plugin.ApiContext)
|
||||
local McpStream = require(Plugin.McpStream)
|
||||
local PatchSet = require(Plugin.PatchSet)
|
||||
local PatchTree = require(Plugin.PatchTree)
|
||||
local preloadAssets = require(Plugin.preloadAssets)
|
||||
|
|
@ -198,6 +199,18 @@ function App:init()
|
|||
self:clearRunningConnectionInfo()
|
||||
end
|
||||
end)
|
||||
|
||||
if RunService:IsEdit() then
|
||||
self.mcpStream = McpStream.new({
|
||||
onSyncCommand = function(requestId)
|
||||
return self:startMcpSync(requestId)
|
||||
end,
|
||||
getPluginConfig = function(key)
|
||||
return Settings:get(key)
|
||||
end,
|
||||
})
|
||||
self.mcpStream:start()
|
||||
end
|
||||
end
|
||||
|
||||
function App:willUnmount()
|
||||
|
|
@ -216,6 +229,11 @@ function App:willUnmount()
|
|||
|
||||
self.autoConnectPlaytestServerListener()
|
||||
self:clearRunningConnectionInfo()
|
||||
|
||||
if self.mcpStream then
|
||||
self.mcpStream:stop()
|
||||
self.mcpStream = nil
|
||||
end
|
||||
end
|
||||
|
||||
function App:addNotification(notif: {
|
||||
|
|
@ -1099,6 +1117,253 @@ function App:endSession()
|
|||
Log.trace("Session terminated by user")
|
||||
end
|
||||
|
||||
function App:startMcpSync(requestId)
|
||||
return Promise.new(function(resolve, _reject)
|
||||
if self.state.appStatus == AppStatus.Connected then
|
||||
resolve({
|
||||
requestId = requestId,
|
||||
status = "already_connected",
|
||||
changes = {},
|
||||
message = "Atlas is already connected in live sync mode. All changes sync automatically.",
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if self.state.appStatus == AppStatus.Connecting or self.state.appStatus == AppStatus.Confirming then
|
||||
resolve({
|
||||
requestId = requestId,
|
||||
status = "sync_in_progress",
|
||||
changes = {},
|
||||
message = "A sync operation is already in progress.",
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if self.serveSession ~= nil then
|
||||
Log.trace("MCP sync: ending existing session before starting")
|
||||
self:endSession()
|
||||
end
|
||||
|
||||
local host = Config.defaultHost
|
||||
local port = Config.defaultPort
|
||||
local baseUrl = ("http://%s:%s"):format(host, port)
|
||||
local apiContext = ApiContext.new(baseUrl)
|
||||
|
||||
local serveSession = ServeSession.new({
|
||||
apiContext = apiContext,
|
||||
twoWaySync = false,
|
||||
})
|
||||
|
||||
local mcpPatchTree = nil
|
||||
local mcpSyncResolved = false
|
||||
|
||||
local function resolveOnce(result)
|
||||
if mcpSyncResolved then
|
||||
return
|
||||
end
|
||||
mcpSyncResolved = true
|
||||
result.requestId = requestId
|
||||
resolve(result)
|
||||
end
|
||||
|
||||
serveSession:setUpdateLoadingTextCallback(function(text: string)
|
||||
self:setState({
|
||||
connectingText = text,
|
||||
})
|
||||
end)
|
||||
|
||||
local cachedServerInfo = nil
|
||||
|
||||
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
|
||||
cachedServerInfo = serverInfo
|
||||
|
||||
PatchSet.removeDataModelName(patch, instanceMap)
|
||||
|
||||
if PatchSet.isEmpty(patch) then
|
||||
Log.info("MCP sync: no changes to sync")
|
||||
resolveOnce({
|
||||
status = "empty",
|
||||
changes = {},
|
||||
})
|
||||
return "Accept"
|
||||
end
|
||||
|
||||
local gitMetadata = serverInfo.gitMetadata
|
||||
local patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
|
||||
mcpPatchTree = patchTree
|
||||
|
||||
local allPreSelected = true
|
||||
local hasSelectableNodes = false
|
||||
patchTree:forEach(function(node)
|
||||
if node.patchType then
|
||||
hasSelectableNodes = true
|
||||
if node.defaultSelection == nil then
|
||||
allPreSelected = false
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not hasSelectableNodes then
|
||||
Log.info("MCP sync: no selectable changes, auto-accepting")
|
||||
resolveOnce({
|
||||
status = "empty",
|
||||
changes = {},
|
||||
})
|
||||
return "Accept"
|
||||
end
|
||||
|
||||
if allPreSelected then
|
||||
Log.info("MCP sync: all changes pre-selected, fast-forwarding")
|
||||
local selections = PatchTree.buildInitialSelections(patchTree)
|
||||
local changes = self:_buildMcpChangeList(patchTree, selections)
|
||||
|
||||
local autoSelectedIds = {}
|
||||
patchTree:forEach(function(node)
|
||||
if node.patchType and node.defaultSelection and node.defaultSelection ~= "ignore" then
|
||||
autoSelectedIds[node.id] = true
|
||||
end
|
||||
end)
|
||||
|
||||
resolveOnce({
|
||||
status = "success",
|
||||
changes = changes,
|
||||
})
|
||||
|
||||
return {
|
||||
type = "Confirm",
|
||||
selections = selections,
|
||||
autoSelectedIds = autoSelectedIds,
|
||||
}
|
||||
end
|
||||
|
||||
Log.info("MCP sync: changes require user review, showing confirmation UI")
|
||||
self:setState({
|
||||
appStatus = AppStatus.Confirming,
|
||||
patchTree = patchTree,
|
||||
confirmData = {
|
||||
serverInfo = serverInfo,
|
||||
},
|
||||
toolbarIcon = Assets.Images.PluginButton,
|
||||
})
|
||||
|
||||
self:addNotification({
|
||||
text = "An AI agent requested a sync. Please review and confirm the changes.",
|
||||
timeout = 10,
|
||||
})
|
||||
|
||||
local result = self.confirmationEvent:Wait()
|
||||
|
||||
if result == "Abort" then
|
||||
local changes = self:_buildMcpChangeList(patchTree, nil)
|
||||
resolveOnce({
|
||||
status = "rejected",
|
||||
changes = changes,
|
||||
message = "User rejected the sync changes.",
|
||||
})
|
||||
return result
|
||||
end
|
||||
|
||||
if type(result) == "table" and result.type == "Confirm" then
|
||||
local changes = self:_buildMcpChangeList(patchTree, result.selections)
|
||||
resolveOnce({
|
||||
status = "success",
|
||||
changes = changes,
|
||||
})
|
||||
return result
|
||||
end
|
||||
|
||||
resolveOnce({
|
||||
status = "success",
|
||||
changes = {},
|
||||
})
|
||||
return result
|
||||
end)
|
||||
|
||||
serveSession:onStatusChanged(function(status, details)
|
||||
if status == ServeSession.Status.Connecting then
|
||||
self:setState({
|
||||
appStatus = AppStatus.Connecting,
|
||||
toolbarIcon = Assets.Images.PluginButton,
|
||||
})
|
||||
elseif status == ServeSession.Status.Connected then
|
||||
self:setState({
|
||||
appStatus = AppStatus.Connecting,
|
||||
connectingText = "Completing sync...",
|
||||
toolbarIcon = Assets.Images.PluginButton,
|
||||
})
|
||||
elseif status == ServeSession.Status.Disconnected then
|
||||
self.serveSession = nil
|
||||
self:setState({
|
||||
appStatus = AppStatus.NotConnected,
|
||||
toolbarIcon = Assets.Images.PluginButton,
|
||||
patchTree = nil,
|
||||
})
|
||||
if details ~= nil then
|
||||
Log.warn("MCP sync disconnected with error: {}", details)
|
||||
resolveOnce({
|
||||
status = "error",
|
||||
changes = {},
|
||||
message = tostring(details),
|
||||
})
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
serveSession:setInitialSyncCompleteCallback(function()
|
||||
Log.info("MCP sync: initial sync complete, disconnecting")
|
||||
if self.serveSession and self.serveSession == serveSession then
|
||||
self:endSession()
|
||||
end
|
||||
end)
|
||||
|
||||
serveSession:start()
|
||||
self.serveSession = serveSession
|
||||
end)
|
||||
end
|
||||
|
||||
function App:_buildMcpChangeList(patchTree, selections)
|
||||
local changes = {}
|
||||
|
||||
patchTree:forEach(function(node)
|
||||
if not node.patchType then
|
||||
return
|
||||
end
|
||||
|
||||
local direction = nil
|
||||
if selections then
|
||||
local sel = selections[node.id]
|
||||
if sel == "push" then
|
||||
direction = "push"
|
||||
elseif sel == "pull" then
|
||||
direction = "pull"
|
||||
else
|
||||
return
|
||||
end
|
||||
else
|
||||
direction = node.defaultSelection or "push"
|
||||
end
|
||||
|
||||
local segments = {}
|
||||
local current = node
|
||||
while current and current.id ~= "ROOT" do
|
||||
table.insert(segments, 1, current.name or current.id)
|
||||
if current.parentId then
|
||||
current = patchTree:getNode(current.parentId)
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local path = table.concat(segments, "/")
|
||||
table.insert(changes, {
|
||||
path = path,
|
||||
direction = direction,
|
||||
})
|
||||
end)
|
||||
|
||||
return changes
|
||||
end
|
||||
|
||||
function App:render()
|
||||
local pluginName = "Atlas " .. Version.display(Config.version)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue