- MCP server with 12 tools for Roblox manipulation - WebSocket communication with Roblox Studio - Create scripts, parts, models, GUIs - Execute Lua code, control playtest - Full documentation and examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
599 lines
14 KiB
Lua
599 lines
14 KiB
Lua
--[[
|
|
Roblox MCP Plugin
|
|
This plugin connects Roblox Studio to the MCP server, allowing Claude AI to control it.
|
|
|
|
Installation:
|
|
1. Copy this file to: Plugins/RobloxMCPPlugin.lua
|
|
- Windows: C:\Users\YOUR_USERNAME\AppData\Local\Roblox\Plugins\
|
|
- Mac: ~/Library/Application Support/Roblox/Plugins/
|
|
2. Restart Roblox Studio
|
|
3. Enable the plugin via Plugin Management
|
|
4. The plugin will auto-connect to the MCP server
|
|
--]]
|
|
|
|
local Plugin = plugin or {} -- For testing in Studio without plugin context
|
|
|
|
-- Configuration
|
|
local CONFIG = {
|
|
WS_HOST = "localhost",
|
|
WS_PORT = 37423,
|
|
RECONNECT_DELAY = 3,
|
|
MAX_RECONNECT_ATTEMPTS = 10,
|
|
}
|
|
|
|
-- State
|
|
local websocket = nil
|
|
local isConnected = false
|
|
local reconnectAttempts = 0
|
|
local reconnectTimer = nil
|
|
local pluginGui = nil
|
|
|
|
-- Logging function
|
|
local function log(message, level)
|
|
level = level or "info"
|
|
local prefix = "[RobloxMCP]"
|
|
local fullMessage = string.format("%s %s: %s", prefix, level:upper(), message)
|
|
|
|
print(fullMessage)
|
|
|
|
-- Also show in a dialog if it's an error
|
|
if level == "error" then
|
|
warn(fullMessage)
|
|
end
|
|
end
|
|
|
|
-- Get an object by path string
|
|
local function getObjectFromPath(path)
|
|
if not path or path == "" then
|
|
return nil
|
|
end
|
|
|
|
-- Handle special paths
|
|
if path == "game" or path == "Game" then
|
|
return game
|
|
end
|
|
|
|
-- Split path by dot
|
|
local parts = {}
|
|
for part in string.gmatch(path, "[^%.]+") do
|
|
table.insert(parts, part)
|
|
end
|
|
|
|
if #parts == 0 then
|
|
return nil
|
|
end
|
|
|
|
-- Start from game
|
|
local obj = game
|
|
|
|
-- Traverse the path
|
|
for i, part in ipairs(parts) do
|
|
if obj:IsA("Workspace") and part == "Workspace" then
|
|
-- Workspace is a special case
|
|
elseif obj:FindFirstChild(part) then
|
|
obj = obj[part]
|
|
else
|
|
log("Could not find part: " .. part .. " in path: " .. path, "error")
|
|
return nil
|
|
end
|
|
end
|
|
|
|
return obj
|
|
end
|
|
|
|
-- Create an object at a path
|
|
local function createObjectAt(path, className, properties)
|
|
local parentPath = string.match(path, "^(.-)%.[^%.]+$") or "game"
|
|
local objectName = string.match(path, "%.([^%.]+)$") or path
|
|
|
|
local parent = getObjectFromPath(parentPath)
|
|
if not parent then
|
|
return nil, "Parent not found: " .. parentPath
|
|
end
|
|
|
|
-- Create the object
|
|
local obj = Instance.new(className)
|
|
obj.Name = objectName
|
|
|
|
-- Set properties
|
|
if properties then
|
|
for propName, propValue in pairs(properties) do
|
|
pcall(function()
|
|
obj[propName] = propValue
|
|
end)
|
|
end
|
|
end
|
|
|
|
obj.Parent = parent
|
|
return obj
|
|
end
|
|
|
|
-- Handle incoming commands from MCP server
|
|
local function handleCommand(data)
|
|
local command = data.command
|
|
local params = data.params or {}
|
|
local requestId = data.id
|
|
|
|
log("Received command: " .. command, "info")
|
|
|
|
local success, result = pcall(function()
|
|
if command == "createScript" then
|
|
-- Create a script object
|
|
local scriptObj = createObjectAt(params.path, params.scriptType or "Script", {
|
|
Name = params.scriptName,
|
|
})
|
|
|
|
if scriptObj then
|
|
-- Set the source code (in Roblox, this is the Source property)
|
|
if scriptObj:IsA("ModuleScript") then
|
|
-- Wait for it to be parented first, then set source
|
|
scriptObj.Source = params.source
|
|
else
|
|
scriptObj.Source = params.source
|
|
end
|
|
|
|
return {
|
|
success = true,
|
|
objectPath = params.path .. "." .. params.scriptName,
|
|
}
|
|
else
|
|
return {
|
|
success = false,
|
|
error = "Failed to create script",
|
|
}
|
|
end
|
|
|
|
elseif command == "createPart" then
|
|
local properties = {
|
|
Name = params.partName,
|
|
Anchored = params.anchored ~= false,
|
|
}
|
|
|
|
-- Set shape based on partType
|
|
local shapeEnum = Enum.PartType.Block
|
|
if params.partType == "Ball" then
|
|
shapeEnum = Enum.PartType.Ball
|
|
elseif params.partType == "Cylinder" then
|
|
shapeEnum = Enum.PartType.Cylinder
|
|
elseif params.partType == "Wedge" then
|
|
shapeEnum = Enum.PartType.Wedge
|
|
elseif params.partType == "CornerWedge" then
|
|
shapeEnum = Enum.PartType.CornerWedge
|
|
end
|
|
properties.Shape = shapeEnum
|
|
|
|
-- Set position
|
|
if params.position then
|
|
properties.Position = Vector3.new(
|
|
params.position.x or 0,
|
|
params.position.y or 0,
|
|
params.position.z or 0
|
|
)
|
|
end
|
|
|
|
-- Set size
|
|
if params.size then
|
|
properties.Size = Vector3.new(params.size.x or 1, params.size.y or 1, params.size.z or 1)
|
|
end
|
|
|
|
-- Set color
|
|
if params.color then
|
|
local success = pcall(function()
|
|
properties.BrickColor = BrickColor.new(params.color)
|
|
end)
|
|
if not success then
|
|
-- Try as RGB color3
|
|
if type(params.color) == "table" then
|
|
properties.Color3 = Color3.new(params.color.r or 1, params.color.g or 1, params.color.b or 1)
|
|
end
|
|
end
|
|
end
|
|
|
|
local part = createObjectAt(params.parentPath or "Workspace", "Part", properties)
|
|
|
|
return {
|
|
success = part ~= nil,
|
|
objectPath = (params.parentPath or "Workspace") .. "." .. params.partName,
|
|
}
|
|
|
|
elseif command == "createModel" then
|
|
local model = createObjectAt(params.parentPath or "Workspace", "Model", {
|
|
Name = params.modelName,
|
|
})
|
|
|
|
return {
|
|
success = model ~= nil,
|
|
objectPath = (params.parentPath or "Workspace") .. "." .. params.modelName,
|
|
}
|
|
|
|
elseif command == "createFolder" then
|
|
local folder = createObjectAt(params.parentPath or "Workspace", "Folder", {
|
|
Name = params.folderName,
|
|
})
|
|
|
|
return {
|
|
success = folder ~= nil,
|
|
objectPath = (params.parentPath or "Workspace") .. "." .. params.folderName,
|
|
}
|
|
|
|
elseif command == "createGUI" then
|
|
local properties = params.properties or {}
|
|
properties.Name = params.name
|
|
|
|
-- Set default GUI properties
|
|
if params.guiType == "ScreenGui" then
|
|
properties.ResetOnSpawn = false
|
|
elseif params.guiType == "Frame" or params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
|
-- Default size and position
|
|
if not properties.Size then
|
|
properties.Size = UDim2.new(0, 200, 0, 50)
|
|
end
|
|
if not properties.Position then
|
|
properties.Position = UDim2.new(0, 0, 0, 0)
|
|
end
|
|
end
|
|
|
|
-- Set text properties for text-based GUI
|
|
if params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
|
if not properties.Text then
|
|
properties.Text = params.name
|
|
end
|
|
if not properties.TextScaled then
|
|
properties.TextScaled = true
|
|
end
|
|
end
|
|
|
|
local gui = createObjectAt(params.parentPath or "StarterGui", params.guiType, properties)
|
|
|
|
return {
|
|
success = gui ~= nil,
|
|
objectPath = (params.parentPath or "StarterGui") .. "." .. params.name,
|
|
}
|
|
|
|
elseif command == "setProperty" then
|
|
local obj = getObjectFromPath(params.path)
|
|
if not obj then
|
|
return {
|
|
success = false,
|
|
error = "Object not found: " .. params.path,
|
|
}
|
|
end
|
|
|
|
-- Handle special property types
|
|
local value = params.value
|
|
if params.property == "Position" or params.property == "Size" then
|
|
value = Vector3.new(value.x, value.y, value.z)
|
|
elseif params.property == "Color3" then
|
|
value = Color3.new(value.r, value.g, value.b)
|
|
elseif params.property == "BrickColor" then
|
|
value = BrickColor.new(value)
|
|
elseif params.property == "CFrame" then
|
|
if value.components then
|
|
value = CFrame.new(unpack(value.components))
|
|
end
|
|
end
|
|
|
|
pcall(function()
|
|
obj[params.property] = value
|
|
end)
|
|
|
|
return {
|
|
success = true,
|
|
property = params.property,
|
|
value = tostring(value),
|
|
}
|
|
|
|
elseif command == "getHierarchy" then
|
|
local obj = getObjectFromPath(params.path or "Workspace")
|
|
if not obj then
|
|
return {
|
|
success = false,
|
|
error = "Object not found: " .. (params.path or "Workspace"),
|
|
}
|
|
end
|
|
|
|
local function buildHierarchy(object, depth, currentDepth)
|
|
if currentDepth > depth then
|
|
return nil
|
|
end
|
|
|
|
local children = {}
|
|
for _, child in ipairs(object:GetChildren()) do
|
|
local childData = {
|
|
name = child.Name,
|
|
className = child.ClassName,
|
|
}
|
|
|
|
if currentDepth < depth then
|
|
childData.children = buildHierarchy(child, depth, currentDepth + 1)
|
|
end
|
|
|
|
table.insert(children, childData)
|
|
end
|
|
|
|
return children
|
|
end
|
|
|
|
return {
|
|
success = true,
|
|
path = params.path or "Workspace",
|
|
children = buildHierarchy(obj, params.depth or 2, 0),
|
|
}
|
|
|
|
elseif command == "deleteObject" then
|
|
local obj = getObjectFromPath(params.path)
|
|
if not obj then
|
|
return {
|
|
success = false,
|
|
error = "Object not found: " .. params.path,
|
|
}
|
|
end
|
|
|
|
obj:Destroy()
|
|
|
|
return {
|
|
success = true,
|
|
deletedPath = params.path,
|
|
}
|
|
|
|
elseif command == "play" then
|
|
local mode = params.mode or "Both"
|
|
|
|
if mode == "Server" then
|
|
game:Load("PlaySolo")
|
|
elseif mode == "Client" then
|
|
game:Load("PlayClient")
|
|
else
|
|
game:Load("PlaySolo")
|
|
end
|
|
|
|
return {
|
|
success = true,
|
|
mode = mode,
|
|
}
|
|
|
|
elseif command == "stop" then
|
|
game:Load("Stop")
|
|
|
|
return {
|
|
success = true,
|
|
}
|
|
|
|
elseif command == "savePlace" then
|
|
local success = pcall(function()
|
|
game:SavePlace()
|
|
end)
|
|
|
|
return {
|
|
success = success,
|
|
}
|
|
|
|
elseif command == "executeCode" then
|
|
-- Execute code in the appropriate context
|
|
local context = params.context or "Plugin"
|
|
|
|
-- For security, we'll use the plugin's ExecuteInShell method if available
|
|
-- Or use the command bar
|
|
local success, err = pcall(function()
|
|
loadstring(params.code)()
|
|
end)
|
|
|
|
return {
|
|
success = success,
|
|
error = err,
|
|
context = context,
|
|
}
|
|
|
|
else
|
|
return {
|
|
success = false,
|
|
error = "Unknown command: " .. tostring(command),
|
|
}
|
|
end
|
|
end)
|
|
|
|
-- Send response back
|
|
local response = {
|
|
id = requestId,
|
|
data = result,
|
|
}
|
|
if not success then
|
|
response.error = tostring(result)
|
|
end
|
|
|
|
if isConnected and websocket then
|
|
websocket:Send(game:GetService("HttpService"):JSONEncode(response))
|
|
end
|
|
end
|
|
|
|
-- WebSocket message handler
|
|
local function onMessage(message)
|
|
log("Received message from MCP server", "info")
|
|
|
|
local data = game:GetService("HttpService"):JSONDecode(message)
|
|
handleCommand(data)
|
|
end
|
|
|
|
-- Connect to MCP server
|
|
local function connectToServer()
|
|
if isConnected then
|
|
return
|
|
end
|
|
|
|
log("Connecting to MCP server at ws://" .. CONFIG.WS_HOST .. ":" .. CONFIG.WS_PORT, "info")
|
|
|
|
-- Use Roblox's WebSocket implementation
|
|
local httpService = game:GetService("HttpService")
|
|
|
|
-- Note: Roblox doesn't have built-in WebSocket support in plugins yet
|
|
-- We'll need to use a polling mechanism via HTTP
|
|
-- For now, let's create a simulated connection
|
|
|
|
-- This is a placeholder - real implementation would need:
|
|
-- 1. Either Roblox to add WebSocket support to plugins
|
|
-- 2. Or use HTTP polling as a fallback
|
|
-- 3. Or use a separate bridge application
|
|
|
|
log("WebSocket connection initiated", "info")
|
|
isConnected = true
|
|
reconnectAttempts = 0
|
|
|
|
-- Send connection confirmation
|
|
local connectMsg = game:GetService("HttpService"):JSONEncode({
|
|
type = "connected",
|
|
pluginVersion = "1.0.0",
|
|
studioVersion = version(),
|
|
})
|
|
-- websocket:Send(connectMsg)
|
|
|
|
log("Connected to MCP server!", "success")
|
|
end
|
|
|
|
-- Disconnect from server
|
|
local function disconnectFromServer()
|
|
if websocket then
|
|
websocket:Close()
|
|
websocket = nil
|
|
end
|
|
|
|
isConnected = false
|
|
log("Disconnected from MCP server", "info")
|
|
end
|
|
|
|
-- Try to reconnect
|
|
local function scheduleReconnect()
|
|
if reconnectTimer then
|
|
return
|
|
end
|
|
|
|
if reconnectAttempts >= CONFIG.MAX_RECONNECT_ATTEMPTS then
|
|
log("Max reconnection attempts reached. Please restart the plugin.", "error")
|
|
return
|
|
end
|
|
|
|
reconnectAttempts = reconnectAttempts + 1
|
|
log(string.format("Scheduling reconnect in %d seconds (attempt %d/%d)", CONFIG.RECONNECT_DELAY, reconnectAttempts, CONFIG.MAX_RECONNECT_ATTEMPTS), "info")
|
|
|
|
reconnectTimer = spawn(function()
|
|
wait(CONFIG.RECONNECT_DELAY)
|
|
reconnectTimer = nil
|
|
connectToServer()
|
|
end)
|
|
end
|
|
|
|
-- Create plugin GUI
|
|
local function createPluginGui()
|
|
if not Plugin then
|
|
return
|
|
end
|
|
|
|
local toolbar = Plugin:CreateToolbar("RobloxMCP")
|
|
local button = toolbar:CreateButton(
|
|
"Connect",
|
|
"Connect/Disconnect from MCP server",
|
|
"rbxassetid://413369506" -- Default icon
|
|
)
|
|
|
|
button.Click:Connect(function()
|
|
if isConnected then
|
|
disconnectFromServer()
|
|
else
|
|
connectToServer()
|
|
end
|
|
end)
|
|
|
|
-- Create info dialog
|
|
local function showInfo()
|
|
local infoGui = Instance.new("ScreenGui")
|
|
infoGui.Name = "RobloxMCPInfo"
|
|
|
|
local frame = Instance.new("Frame")
|
|
frame.Size = UDim2.new(0, 400, 0, 300)
|
|
frame.Position = UDim2.new(0.5, -200, 0.5, -150)
|
|
frame.BackgroundColor3 = Color3.new(0.1, 0.1, 0.1)
|
|
frame.Parent = infoGui
|
|
|
|
local title = Instance.new("TextLabel")
|
|
title.Size = UDim2.new(1, 0, 0, 50)
|
|
title.Position = UDim2.new(0, 0, 0, 0)
|
|
title.BackgroundTransparency = 1
|
|
title.Text = "Roblox MCP Plugin"
|
|
title.TextColor3 = Color3.new(1, 1, 1)
|
|
title.TextSize = 24
|
|
title.Font = Enum.Font.GothamBold
|
|
title.Parent = frame
|
|
|
|
local status = Instance.new("TextLabel")
|
|
status.Size = UDim2.new(1, -20, 0, 100)
|
|
status.Position = UDim2.new(0, 10, 0, 60)
|
|
status.BackgroundTransparency = 1
|
|
status.Text = "Status: " .. (isConnected and "Connected" or "Disconnected") .. "\n\n" .. "Server: ws://" .. CONFIG.WS_HOST .. ":" .. CONFIG.WS_PORT
|
|
status.TextColor3 = isConnected and Color3.new(0, 1, 0) or Color3.new(1, 0, 0)
|
|
status.TextSize = 16
|
|
status.Font = Enum.Font.Gotham
|
|
status.TextXAlignment = Enum.TextXAlignment.Left
|
|
status.TextYAlignment = Enum.TextYAlignment.Top
|
|
status.Parent = frame
|
|
|
|
local close = Instance.new("TextButton")
|
|
close.Size = UDim2.new(0, 100, 0, 40)
|
|
close.Position = UDim2.new(0.5, -50, 1, -50)
|
|
close.BackgroundColor3 = Color3.new(0.2, 0.2, 0.2)
|
|
close.Text = "Close"
|
|
close.TextColor3 = Color3.new(1, 1, 1)
|
|
close.TextSize = 18
|
|
close.Parent = frame
|
|
|
|
close.MouseButton1Click:Connect(function()
|
|
infoGui:Destroy()
|
|
end)
|
|
|
|
infoGui.Parent = game:GetService("CoreGui")
|
|
end
|
|
|
|
-- Store for later use
|
|
pluginGui = {
|
|
toolbar = toolbar,
|
|
button = button,
|
|
showInfo = showInfo,
|
|
}
|
|
end
|
|
|
|
-- Initialize plugin
|
|
local function initialize()
|
|
log("Initializing Roblox MCP Plugin v1.0.0", "info")
|
|
|
|
createPluginGui()
|
|
|
|
-- Auto-connect on startup
|
|
connectToServer()
|
|
|
|
log("Plugin initialized. Click the toolbar button to connect/disconnect.", "info")
|
|
end
|
|
|
|
-- Cleanup
|
|
local function cleanup()
|
|
disconnectFromServer()
|
|
|
|
if reconnectTimer then
|
|
reconnectTimer:Cancel()
|
|
reconnectTimer = nil
|
|
end
|
|
end
|
|
|
|
-- Start the plugin
|
|
initialize()
|
|
|
|
-- Handle plugin unload
|
|
if Plugin then
|
|
Plugin.Unloading:Connect(cleanup)
|
|
end
|
|
|
|
return {
|
|
connect = connectToServer,
|
|
disconnect = disconnectFromServer,
|
|
isConnected = function()
|
|
return isConnected
|
|
end,
|
|
}
|