- Updated RobloxMCPPlugin with HTTP polling (auto-enables HttpService) - Added 20-weapon FPS game example (CoD-style) - Added Python studio-inject.py for command bar injection via Win32 API - Added auto-connect setup scripts (VBS + PowerShell) - Updated MCP server with all FPS game tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
430 lines
12 KiB
Lua
430 lines
12 KiB
Lua
--[[
|
|
Roblox MCP Plugin v2.0 - HTTP Polling Edition
|
|
Connects Roblox Studio to Claude Code via MCP server.
|
|
|
|
Installation:
|
|
1. Copy this file to: %LOCALAPPDATA%\Roblox\Plugins\RobloxMCPPlugin.lua
|
|
2. Open Roblox Studio
|
|
3. Enable HttpService in: Game Settings > Security > Allow HTTP Requests = ON
|
|
4. The plugin will auto-connect to the MCP server
|
|
5. Click the toolbar button to connect/disconnect
|
|
|
|
Requirements:
|
|
- MCP server running: node C:\Users\Admin\roblox-mcp-server\src\index.js
|
|
- HttpService enabled in Game Settings > Security
|
|
--]]
|
|
|
|
local Plugin = plugin or {}
|
|
|
|
-- Configuration
|
|
local CONFIG = {
|
|
HOST = "localhost",
|
|
PORT = 37423,
|
|
POLL_INTERVAL = 0.5, -- seconds between polls
|
|
RECONNECT_DELAY = 5,
|
|
MAX_RECONNECT_ATTEMPTS = 20,
|
|
}
|
|
|
|
-- Build base URL
|
|
local BASE_URL = "http://" .. CONFIG.HOST .. ":" .. CONFIG.PORT
|
|
|
|
-- State
|
|
local isConnected = false
|
|
local isPolling = false
|
|
local reconnectAttempts = 0
|
|
local lastCommandId = 0
|
|
local pollThread = nil
|
|
local toolbar = nil
|
|
local connectButton = nil
|
|
|
|
-- Get HttpService and FORCE ENABLE it (plugins can do this in Studio)
|
|
local HttpService = game:GetService("HttpService")
|
|
HttpService.HttpEnabled = true
|
|
|
|
-- Logging
|
|
local function log(message, level)
|
|
level = level or "INFO"
|
|
print("[RobloxMCP] [" .. level .. "] " .. message)
|
|
if level == "ERROR" then
|
|
warn("[RobloxMCP] " .. message)
|
|
end
|
|
end
|
|
|
|
-- Safe HTTP request wrapper
|
|
local function httpRequest(method, path, body)
|
|
local url = BASE_URL .. path
|
|
local ok, result = pcall(function()
|
|
local options = {
|
|
Url = url,
|
|
Method = method,
|
|
Headers = {
|
|
["Content-Type"] = "application/json",
|
|
},
|
|
}
|
|
if body then
|
|
options.Body = HttpService:JSONEncode(body)
|
|
end
|
|
local response = HttpService:RequestAsync(options)
|
|
return response
|
|
end)
|
|
if not ok then
|
|
return false, tostring(result)
|
|
end
|
|
if result.StatusCode ~= 200 then
|
|
return false, "HTTP " .. result.StatusCode .. ": " .. result.Body
|
|
end
|
|
local decoded = HttpService:JSONDecode(result.Body)
|
|
return true, decoded
|
|
end
|
|
|
|
-- Get an object by dot-separated path
|
|
local function getObjectFromPath(path)
|
|
if not path or path == "" then
|
|
return nil
|
|
end
|
|
if path == "game" or path == "Game" then
|
|
return game
|
|
end
|
|
if path == "Workspace" or path == "workspace" then
|
|
return workspace
|
|
end
|
|
|
|
-- Handle special service names
|
|
local serviceMap = {
|
|
["ReplicatedStorage"] = game:GetService("ReplicatedStorage"),
|
|
["ServerStorage"] = game:GetService("ServerStorage"),
|
|
["ServerScriptService"] = game:GetService("ServerScriptService"),
|
|
["StarterGui"] = game:GetService("StarterGui"),
|
|
["StarterPack"] = game:GetService("StarterPack"),
|
|
["StarterPlayer"] = game:GetService("StarterPlayer"),
|
|
["Lighting"] = game:GetService("Lighting"),
|
|
["Players"] = game:GetService("Players"),
|
|
["Workspace"] = workspace,
|
|
}
|
|
|
|
local parts = {}
|
|
for part in string.gmatch(path, "[^%.]+") do
|
|
table.insert(parts, part)
|
|
end
|
|
if #parts == 0 then return nil end
|
|
|
|
-- Start from service or game
|
|
local obj = serviceMap[parts[1]] or game
|
|
|
|
for i = (serviceMap[parts[1]] and 2 or 1), #parts do
|
|
local part = parts[i]
|
|
local child = obj:FindFirstChild(part)
|
|
if not child then
|
|
log("Could not find '" .. part .. "' in path: " .. path, "ERROR")
|
|
return nil
|
|
end
|
|
obj = child
|
|
end
|
|
return obj
|
|
end
|
|
|
|
-- Create object at path
|
|
local function createObjectAt(path, className, properties)
|
|
local parentPath = string.match(path, "^(.-)%.[^%.]+$") or "Workspace"
|
|
local objectName = string.match(path, "%.([^%.]+)$") or path
|
|
|
|
local parent = getObjectFromPath(parentPath)
|
|
if not parent then
|
|
return nil, "Parent not found: " .. parentPath
|
|
end
|
|
|
|
local obj = Instance.new(className)
|
|
obj.Name = objectName
|
|
|
|
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 a single command from MCP server
|
|
local function handleCommand(cmd)
|
|
local command = cmd.command
|
|
local params = cmd.params or {}
|
|
|
|
log("Executing: " .. command)
|
|
|
|
local success, result = pcall(function()
|
|
if command == "createScript" then
|
|
local scriptObj = createObjectAt(params.path, params.scriptType or "Script", {
|
|
Name = params.scriptName,
|
|
})
|
|
if scriptObj then
|
|
pcall(function() scriptObj.Source = params.source end)
|
|
return { success = true, objectPath = params.path }
|
|
else
|
|
return { success = false, error = "Failed to create script" }
|
|
end
|
|
|
|
elseif command == "createPart" then
|
|
local props = { Name = params.partName, Anchored = params.anchored ~= false }
|
|
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
|
|
props.Shape = shapeEnum
|
|
|
|
if params.position then
|
|
props.Position = Vector3.new(params.position.x or 0, params.position.y or 0, params.position.z or 0)
|
|
end
|
|
if params.size then
|
|
props.Size = Vector3.new(params.size.x or 4, params.size.y or 4, params.size.z or 4)
|
|
end
|
|
if params.color then
|
|
pcall(function() props.Color = BrickColor.new(params.color).Color end)
|
|
end
|
|
|
|
local part = createObjectAt(params.parentPath or "Workspace", "Part", props)
|
|
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
|
|
if params.guiType == "ScreenGui" then
|
|
properties.ResetOnSpawn = false
|
|
end
|
|
if params.guiType == "Frame" or params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
|
if not properties.Size then properties.Size = UDim2.new(0, 200, 0, 50) end
|
|
end
|
|
if params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
|
if not properties.Text then properties.Text = params.name 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
|
|
|
|
local value = params.value
|
|
pcall(function()
|
|
if params.property == "Position" or params.property == "Size" then
|
|
value = Vector3.new(value.x, value.y, value.z)
|
|
elseif params.property == "CFrame" then
|
|
if value.components then value = CFrame.new(unpack(value.components)) end
|
|
elseif params.property == "Color" then
|
|
value = BrickColor.new(value).Color
|
|
end
|
|
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" } end
|
|
|
|
local function buildTree(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 = buildTree(child, depth, currentDepth + 1)
|
|
end
|
|
table.insert(children, childData)
|
|
end
|
|
return children
|
|
end
|
|
|
|
return { success = true, path = params.path or "Workspace", children = buildTree(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
|
|
pcall(function() game:Load("PlaySolo") end)
|
|
return { success = true, mode = params.mode or "Both" }
|
|
|
|
elseif command == "stop" then
|
|
pcall(function() game:Load("Stop") end)
|
|
return { success = true }
|
|
|
|
elseif command == "savePlace" then
|
|
local ok = pcall(function() game:SavePlace() end)
|
|
return { success = ok }
|
|
|
|
elseif command == "executeCode" then
|
|
local fn, err = loadstring(params.code)
|
|
if not fn then return { success = false, error = err } end
|
|
local ok, execErr = pcall(fn)
|
|
return { success = ok, error = not ok and tostring(execErr) or nil }
|
|
|
|
else
|
|
return { success = false, error = "Unknown command: " .. tostring(command) }
|
|
end
|
|
end)
|
|
|
|
-- Build response
|
|
local response = {
|
|
id = cmd.id,
|
|
result = success and result or { success = false, error = tostring(result) },
|
|
}
|
|
if not success then
|
|
response.error = tostring(result)
|
|
end
|
|
return response
|
|
end
|
|
|
|
-- Poll the MCP server for new commands
|
|
local function pollOnce()
|
|
local ok, data = httpRequest("GET", "/poll?last=" .. tostring(lastCommandId))
|
|
if not ok then
|
|
return false, data
|
|
end
|
|
|
|
if data.commands and #data.commands > 0 then
|
|
for _, cmd in ipairs(data.commands) do
|
|
log("Received command: " .. (cmd.command or "unknown"))
|
|
|
|
-- Execute command
|
|
local response = handleCommand(cmd)
|
|
|
|
-- Send result back to MCP server
|
|
local sendOk, sendErr = httpRequest("POST", "/result", {
|
|
id = cmd.id,
|
|
result = response.result,
|
|
})
|
|
|
|
if not sendOk then
|
|
log("Failed to send result: " .. tostring(sendErr), "ERROR")
|
|
else
|
|
log("Command completed: " .. (cmd.command or "unknown"))
|
|
end
|
|
|
|
lastCommandId = math.max(lastCommandId, cmd.id)
|
|
end
|
|
|
|
-- Update lastCommandId from server
|
|
if data.lastId then
|
|
lastCommandId = math.max(lastCommandId, data.lastId)
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
-- Main polling loop
|
|
local function startPolling()
|
|
if isPolling then return end
|
|
isPolling = true
|
|
log("Starting poll loop...")
|
|
|
|
pollThread = spawn(function()
|
|
while isConnected and isPolling do
|
|
local ok, err = pollOnce()
|
|
if not ok then
|
|
log("Poll error: " .. tostring(err), "ERROR")
|
|
-- Don't disconnect on single poll failure, just wait
|
|
end
|
|
wait(CONFIG.POLL_INTERVAL)
|
|
end
|
|
log("Poll loop stopped")
|
|
end)
|
|
end
|
|
|
|
-- Connect to MCP server
|
|
local function connect()
|
|
if isConnected then return end
|
|
|
|
log("Connecting to MCP server at " .. BASE_URL .. " ...")
|
|
|
|
-- Health check first
|
|
local ok, data = httpRequest("GET", "/health")
|
|
if not ok then
|
|
log("MCP server not reachable: " .. tostring(data), "ERROR")
|
|
log("Make sure the MCP server is running: node C:\\Users\\Admin\\roblox-mcp-server\\src\\index.js", "ERROR")
|
|
return false
|
|
end
|
|
|
|
isConnected = true
|
|
reconnectAttempts = 0
|
|
lastCommandId = 0
|
|
log("Connected to MCP server!")
|
|
startPolling()
|
|
return true
|
|
end
|
|
|
|
-- Disconnect from MCP server
|
|
local function disconnect()
|
|
isConnected = false
|
|
isPolling = false
|
|
if pollThread then
|
|
pollThread = nil
|
|
end
|
|
log("Disconnected from MCP server")
|
|
end
|
|
|
|
-- Create toolbar button
|
|
local function createUI()
|
|
if not Plugin or not Plugin:FindFirstChildWhichIsA("Toolbar") then
|
|
toolbar = Plugin:CreateToolbar("RobloxMCP")
|
|
end
|
|
|
|
connectButton = toolbar:CreateButton(
|
|
"MCP Connect",
|
|
"Connect/Disconnect from Claude Code MCP server",
|
|
"rbxassetid://16706090882"
|
|
)
|
|
|
|
connectButton.Click:Connect(function()
|
|
if isConnected then
|
|
disconnect()
|
|
connectButton.Icon = "rbxassetid://16706090882"
|
|
else
|
|
local success = connect()
|
|
if success then
|
|
connectButton.Icon = "rbxassetid://16706100672"
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- Initialize
|
|
local function init()
|
|
log("Roblox MCP Plugin v2.0 (HTTP Polling) loaded")
|
|
log("Make sure HttpService is enabled: Game Settings > Security > Allow HTTP Requests")
|
|
createUI()
|
|
|
|
-- Auto-connect attempt
|
|
spawn(function()
|
|
wait(2) -- Wait for Studio to fully load
|
|
connect()
|
|
if isConnected and connectButton then
|
|
connectButton.Icon = "rbxassetid://16706100672"
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- Cleanup on unload
|
|
if Plugin then
|
|
Plugin.Unloading:Connect(function()
|
|
disconnect()
|
|
end)
|
|
end
|
|
|
|
init()
|