robloxstudio-mcp 2.6.0-next.3 → 2.6.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -64,13 +64,8 @@ async function download(url, dest, redirects = 0) {
64
64
  res.on("error", cleanup);
65
65
  });
66
66
  }
67
- async function installPlugin() {
68
- const pluginsFolder = getPluginsFolder();
69
- if (!existsSync2(pluginsFolder)) {
70
- mkdirSync2(pluginsFolder, { recursive: true });
71
- }
72
- console.log("Fetching latest release...");
73
- const res = await httpsGet(`https://api.github.com/repos/${REPO}/releases/latest`);
67
+ async function fetchJson(url) {
68
+ const res = await httpsGet(url);
74
69
  if (res.statusCode !== 200) {
75
70
  throw new Error(`GitHub API returned HTTP ${res.statusCode}`);
76
71
  }
@@ -78,7 +73,26 @@ async function installPlugin() {
78
73
  for await (const chunk of res) {
79
74
  chunks.push(chunk);
80
75
  }
81
- const release = JSON.parse(Buffer.concat(chunks).toString());
76
+ return JSON.parse(Buffer.concat(chunks).toString());
77
+ }
78
+ async function findDevRelease() {
79
+ const releases = await fetchJson(`https://api.github.com/repos/${REPO}/releases?per_page=20`);
80
+ const prerelease = releases.find(
81
+ (r) => r.prerelease && r.assets.some((a) => a.name === ASSET_NAME)
82
+ );
83
+ if (!prerelease) {
84
+ throw new Error(`No prerelease found with ${ASSET_NAME}`);
85
+ }
86
+ return prerelease;
87
+ }
88
+ async function installPlugin() {
89
+ const dev = process.argv.includes("--dev");
90
+ const pluginsFolder = getPluginsFolder();
91
+ if (!existsSync2(pluginsFolder)) {
92
+ mkdirSync2(pluginsFolder, { recursive: true });
93
+ }
94
+ console.log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
95
+ const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
82
96
  const asset = release.assets?.find((a) => a.name === ASSET_NAME);
83
97
  if (!asset) {
84
98
  throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
@@ -146,7 +160,7 @@ var TOOL_HANDLERS = {
146
160
  }),
147
161
  get_script_source: (tools, body) => tools.getScriptSource(body.instancePath, body.startLine, body.endLine),
148
162
  set_script_source: (tools, body) => tools.setScriptSource(body.instancePath, body.source),
149
- edit_script_lines: (tools, body) => tools.editScriptLines(body.instancePath, body.startLine, body.endLine, body.newContent),
163
+ edit_script_lines: (tools, body) => tools.editScriptLines(body.instancePath, body.old_string, body.new_string),
150
164
  insert_script_lines: (tools, body) => tools.insertScriptLines(body.instancePath, body.afterLine, body.newContent),
151
165
  delete_script_lines: (tools, body) => tools.deleteScriptLines(body.instancePath, body.startLine, body.endLine),
152
166
  get_attribute: (tools, body) => tools.getAttribute(body.instancePath, body.attributeName),
@@ -1479,11 +1493,11 @@ var RobloxStudioTools = class _RobloxStudioTools {
1479
1493
  ]
1480
1494
  };
1481
1495
  }
1482
- async editScriptLines(instancePath, startLine, endLine, newContent) {
1483
- if (!instancePath || !startLine || !endLine || typeof newContent !== "string") {
1484
- throw new Error("Instance path, startLine, endLine, and newContent are required for edit_script_lines");
1496
+ async editScriptLines(instancePath, oldString, newString) {
1497
+ if (!instancePath || typeof oldString !== "string" || typeof newString !== "string") {
1498
+ throw new Error("Instance path, old_string, and new_string are required for edit_script_lines");
1485
1499
  }
1486
- const response = await this.client.request("/api/edit-script-lines", { instancePath, startLine, endLine, newContent });
1500
+ const response = await this.client.request("/api/edit-script-lines", { instancePath, old_string: oldString, new_string: newString });
1487
1501
  return {
1488
1502
  content: [
1489
1503
  {
@@ -2701,7 +2715,7 @@ var RobloxStudioMCPServer = class {
2701
2715
  case "set_script_source":
2702
2716
  return await this.tools.setScriptSource(args?.instancePath, args?.source);
2703
2717
  case "edit_script_lines":
2704
- return await this.tools.editScriptLines(args?.instancePath, args?.startLine, args?.endLine, args?.newContent);
2718
+ return await this.tools.editScriptLines(args?.instancePath, args?.old_string, args?.new_string);
2705
2719
  case "insert_script_lines":
2706
2720
  return await this.tools.insertScriptLines(args?.instancePath, args?.afterLine, args?.newContent);
2707
2721
  case "delete_script_lines":
@@ -3480,7 +3494,7 @@ var TOOL_DEFINITIONS = [
3480
3494
  {
3481
3495
  name: "edit_script_lines",
3482
3496
  category: "write",
3483
- description: "Replace a range of lines. 1-indexed, inclusive. Use numberedSource for line numbers.",
3497
+ description: "Replace exact text in a script. old_string must match exactly once in the script (whitespace-sensitive). Use get_script_source first to see current content.",
3484
3498
  inputSchema: {
3485
3499
  type: "object",
3486
3500
  properties: {
@@ -3488,20 +3502,16 @@ var TOOL_DEFINITIONS = [
3488
3502
  type: "string",
3489
3503
  description: "Script instance path"
3490
3504
  },
3491
- startLine: {
3492
- type: "number",
3493
- description: "Start line (1-indexed)"
3494
- },
3495
- endLine: {
3496
- type: "number",
3497
- description: "End line (inclusive)"
3505
+ old_string: {
3506
+ type: "string",
3507
+ description: "Exact text to find and replace (must be unique in the script)"
3498
3508
  },
3499
- newContent: {
3509
+ new_string: {
3500
3510
  type: "string",
3501
- description: "Replacement content"
3511
+ description: "Replacement text"
3502
3512
  }
3503
3513
  },
3504
- required: ["instancePath", "startLine", "endLine", "newContent"]
3514
+ required: ["instancePath", "old_string", "new_string"]
3505
3515
  }
3506
3516
  },
3507
3517
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robloxstudio-mcp",
3
- "version": "2.6.0-next.3",
3
+ "version": "2.6.0-next.4",
4
4
  "description": "MCP Server for Roblox Studio Integration - Access Studio data, scripts, and objects through AI tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -57,6 +57,17 @@ local AssetHandlers = TS.import(script, script.Parent, "handlers", "AssetHandler
57
57
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
58
58
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
59
59
  local RenderHandlers = TS.import(script, script.Parent, "handlers", "RenderHandlers")
60
+ local instanceId = HttpService:GenerateGUID(false)
61
+ local assignedRole
62
+ local function detectRole()
63
+ if not RunService:IsRunMode() then
64
+ return "edit"
65
+ end
66
+ if RunService:IsServer() then
67
+ return "server"
68
+ end
69
+ return "client"
70
+ end
60
71
  local routeMap = {
61
72
  ["/api/file-tree"] = QueryHandlers.getFileTree,
62
73
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -153,7 +164,6 @@ local function getConnectionStatus(connIndex)
153
164
  end
154
165
  return "connecting"
155
166
  end
156
- local discoverPort
157
167
  local function pollForRequests(connIndex)
158
168
  local conn = State.getConnection(connIndex)
159
169
  if not conn or not conn.isActive then
@@ -165,7 +175,7 @@ local function pollForRequests(connIndex)
165
175
  conn.isPolling = true
166
176
  local success, result = pcall(function()
167
177
  return HttpService:RequestAsync({
168
- Url = `{conn.serverUrl}/poll`,
178
+ Url = `{conn.serverUrl}/poll?instanceId={instanceId}`,
169
179
  Method = "GET",
170
180
  Headers = {
171
181
  ["Content-Type"] = "application/json",
@@ -182,6 +192,7 @@ local function pollForRequests(connIndex)
182
192
  local data = HttpService:JSONDecode(result.Body)
183
193
  local mcpConnected = data.mcpConnected == true
184
194
  conn.lastHttpOk = true
195
+ conn.lastMcpOk = mcpConnected
185
196
  if connIndex == State.getActiveTabIndex() then
186
197
  local el = ui
187
198
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
@@ -228,18 +239,6 @@ local function pollForRequests(connIndex)
228
239
  end
229
240
  local elapsed = _exp - _condition_1
230
241
  el.troubleshootLabel.Visible = elapsed > 8
231
- if elapsed > 3 and elapsed % 5 < conn.pollInterval then
232
- task.spawn(function()
233
- local discovered = discoverPort()
234
- if discovered ~= nil and discovered ~= conn.port then
235
- conn.port = discovered
236
- conn.serverUrl = `http://localhost:{discovered}`
237
- if connIndex == State.getActiveTabIndex() then
238
- UI.getElements().urlInput.Text = conn.serverUrl
239
- end
240
- end
241
- end)
242
- end
243
242
  UI.startPulseAnimation()
244
243
  end
245
244
  end
@@ -262,20 +261,6 @@ local function pollForRequests(connIndex)
262
261
  if conn.consecutiveFailures > 1 then
263
262
  conn.currentRetryDelay = math.min(conn.currentRetryDelay * conn.retryBackoffMultiplier, conn.maxRetryDelay)
264
263
  end
265
- if conn.consecutiveFailures == 5 or conn.consecutiveFailures % 20 == 0 then
266
- task.spawn(function()
267
- local discovered = discoverPort()
268
- if discovered ~= nil and discovered ~= conn.port then
269
- conn.port = discovered
270
- conn.serverUrl = `http://localhost:{discovered}`
271
- conn.consecutiveFailures = 0
272
- conn.currentRetryDelay = 0.5
273
- if connIndex == State.getActiveTabIndex() then
274
- UI.getElements().urlInput.Text = conn.serverUrl
275
- end
276
- end
277
- end)
278
- end
279
264
  if connIndex == State.getActiveTabIndex() then
280
265
  local el = ui
281
266
  if conn.consecutiveFailures >= conn.maxFailuresBeforeError then
@@ -334,35 +319,6 @@ local function pollForRequests(connIndex)
334
319
  end
335
320
  end
336
321
  end
337
- function discoverPort()
338
- local firstActivePort
339
- for offset = 0, 4 do
340
- local port = State.BASE_PORT + offset
341
- local success, result = pcall(function()
342
- return HttpService:RequestAsync({
343
- Url = `http://localhost:{port}/status`,
344
- Method = "GET",
345
- Headers = {
346
- ["Content-Type"] = "application/json",
347
- },
348
- })
349
- end)
350
- if success and result.Success then
351
- local ok, data = pcall(function()
352
- return HttpService:JSONDecode(result.Body)
353
- end)
354
- if ok and data.mcpServerActive then
355
- if not data.pluginConnected then
356
- return port
357
- end
358
- if firstActivePort == nil then
359
- firstActivePort = port
360
- end
361
- end
362
- end
363
- end
364
- return firstActivePort
365
- end
366
322
  local function activatePlugin(connIndex)
367
323
  local _condition = connIndex
368
324
  if _condition == nil then
@@ -388,18 +344,11 @@ local function activatePlugin(connIndex)
388
344
  end
389
345
  conn.port = _condition_1
390
346
  end
347
+ UI.updateTabLabel(idx)
391
348
  UI.updateUIState()
392
349
  end
393
350
  UI.updateTabDot(idx)
394
351
  task.spawn(function()
395
- local discoveredPort = discoverPort()
396
- if discoveredPort ~= nil then
397
- conn.port = discoveredPort
398
- conn.serverUrl = `http://localhost:{discoveredPort}`
399
- if idx == State.getActiveTabIndex() then
400
- ui.urlInput.Text = conn.serverUrl
401
- end
402
- end
403
352
  if not conn.heartbeatConnection then
404
353
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
405
354
  local now = tick()
@@ -410,19 +359,30 @@ local function activatePlugin(connIndex)
410
359
  end
411
360
  end)
412
361
  end
413
- pcall(function()
414
- HttpService:RequestAsync({
362
+ local readyOk, readyResult = pcall(function()
363
+ return HttpService:RequestAsync({
415
364
  Url = `{conn.serverUrl}/ready`,
416
365
  Method = "POST",
417
366
  Headers = {
418
367
  ["Content-Type"] = "application/json",
419
368
  },
420
369
  Body = HttpService:JSONEncode({
370
+ instanceId = instanceId,
371
+ role = detectRole(),
421
372
  pluginReady = true,
422
373
  timestamp = tick(),
423
374
  }),
424
375
  })
425
376
  end)
377
+ if readyOk and readyResult.Success then
378
+ local parseOk, readyData = pcall(function()
379
+ return HttpService:JSONDecode(readyResult.Body)
380
+ end)
381
+ local _value = parseOk and readyData.assignedRole
382
+ if _value ~= "" and _value then
383
+ assignedRole = readyData.assignedRole
384
+ end
385
+ end
426
386
  end)
427
387
  end
428
388
  local function deactivatePlugin(connIndex)
@@ -436,6 +396,7 @@ local function deactivatePlugin(connIndex)
436
396
  return nil
437
397
  end
438
398
  conn.isActive = false
399
+ conn.lastMcpOk = false
439
400
  if idx == State.getActiveTabIndex() then
440
401
  UI.updateUIState()
441
402
  end
@@ -448,6 +409,7 @@ local function deactivatePlugin(connIndex)
448
409
  ["Content-Type"] = "application/json",
449
410
  },
450
411
  Body = HttpService:JSONEncode({
412
+ instanceId = instanceId,
451
413
  timestamp = tick(),
452
414
  }),
453
415
  })
@@ -1375,33 +1337,57 @@ local CaptureService = game:GetService("CaptureService")
1375
1337
  local AssetService = game:GetService("AssetService")
1376
1338
  local MAX_TILE_SIZE = 1024
1377
1339
  local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
1340
+ local PAD_BYTE = (string.byte("="))
1341
+ local B64 = {}
1342
+ for i = 0, 63 do
1343
+ B64[i + 1] = (string.byte(BASE64_CHARS, i + 1))
1344
+ end
1378
1345
  local function encodeBase64(buf)
1379
1346
  local len = buffer.len(buf)
1380
- local parts = {}
1381
- local i = 0
1382
- while i + 2 < len do
1383
- local b0 = buffer.readu8(buf, i)
1384
- local b1 = buffer.readu8(buf, i + 1)
1385
- local b2 = buffer.readu8(buf, i + 2)
1386
- local triplet = bit32.lshift(b0, 16) + bit32.lshift(b1, 8) + b2
1387
- local _arg0 = string.sub(BASE64_CHARS, bit32.rshift(triplet, 18) + 1, bit32.rshift(triplet, 18) + 1) .. string.sub(BASE64_CHARS, bit32.band(bit32.rshift(triplet, 12), 63) + 1, bit32.band(bit32.rshift(triplet, 12), 63) + 1) .. string.sub(BASE64_CHARS, bit32.band(bit32.rshift(triplet, 6), 63) + 1, bit32.band(bit32.rshift(triplet, 6), 63) + 1) .. string.sub(BASE64_CHARS, bit32.band(triplet, 63) + 1, bit32.band(triplet, 63) + 1)
1388
- table.insert(parts, _arg0)
1389
- i += 3
1347
+ local fullTriples = math.floor(len / 3)
1348
+ local remaining = len - fullTriples * 3
1349
+ local outLen = (fullTriples + (if remaining > 0 then 1 else 0)) * 4
1350
+ local out = buffer.create(outLen)
1351
+ local si = 0
1352
+ local di = 0
1353
+ do
1354
+ local t = 0
1355
+ local _shouldIncrement = false
1356
+ while true do
1357
+ if _shouldIncrement then
1358
+ t += 1
1359
+ else
1360
+ _shouldIncrement = true
1361
+ end
1362
+ if not (t < fullTriples) then
1363
+ break
1364
+ end
1365
+ local b0 = buffer.readu8(buf, si)
1366
+ local b1 = buffer.readu8(buf, si + 1)
1367
+ local b2 = buffer.readu8(buf, si + 2)
1368
+ buffer.writeu8(out, di, B64[bit32.rshift(b0, 2) + 1])
1369
+ buffer.writeu8(out, di + 1, B64[bit32.bor(bit32.lshift(bit32.band(b0, 3), 4), bit32.rshift(b1, 4)) + 1])
1370
+ buffer.writeu8(out, di + 2, B64[bit32.bor(bit32.lshift(bit32.band(b1, 15), 2), bit32.rshift(b2, 6)) + 1])
1371
+ buffer.writeu8(out, di + 3, B64[bit32.band(b2, 63) + 1])
1372
+ si += 3
1373
+ di += 4
1374
+ end
1390
1375
  end
1391
- local remaining = len - i
1392
1376
  if remaining == 2 then
1393
- local b0 = buffer.readu8(buf, i)
1394
- local b1 = buffer.readu8(buf, i + 1)
1395
- local triplet = bit32.lshift(b0, 16) + bit32.lshift(b1, 8)
1396
- local _arg0 = string.sub(BASE64_CHARS, bit32.rshift(triplet, 18) + 1, bit32.rshift(triplet, 18) + 1) .. string.sub(BASE64_CHARS, bit32.band(bit32.rshift(triplet, 12), 63) + 1, bit32.band(bit32.rshift(triplet, 12), 63) + 1) .. string.sub(BASE64_CHARS, bit32.band(bit32.rshift(triplet, 6), 63) + 1, bit32.band(bit32.rshift(triplet, 6), 63) + 1) .. "="
1397
- table.insert(parts, _arg0)
1377
+ local b0 = buffer.readu8(buf, si)
1378
+ local b1 = buffer.readu8(buf, si + 1)
1379
+ buffer.writeu8(out, di, B64[bit32.rshift(b0, 2) + 1])
1380
+ buffer.writeu8(out, di + 1, B64[bit32.bor(bit32.lshift(bit32.band(b0, 3), 4), bit32.rshift(b1, 4)) + 1])
1381
+ buffer.writeu8(out, di + 2, B64[bit32.lshift(bit32.band(b1, 15), 2) + 1])
1382
+ buffer.writeu8(out, di + 3, PAD_BYTE)
1398
1383
  elseif remaining == 1 then
1399
- local b0 = buffer.readu8(buf, i)
1400
- local triplet = bit32.lshift(b0, 16)
1401
- local _arg0 = string.sub(BASE64_CHARS, bit32.rshift(triplet, 18) + 1, bit32.rshift(triplet, 18) + 1) .. string.sub(BASE64_CHARS, bit32.band(bit32.rshift(triplet, 12), 63) + 1, bit32.band(bit32.rshift(triplet, 12), 63) + 1) .. "=="
1402
- table.insert(parts, _arg0)
1384
+ local b0 = buffer.readu8(buf, si)
1385
+ buffer.writeu8(out, di, B64[bit32.rshift(b0, 2) + 1])
1386
+ buffer.writeu8(out, di + 1, B64[bit32.lshift(bit32.band(b0, 3), 4) + 1])
1387
+ buffer.writeu8(out, di + 2, PAD_BYTE)
1388
+ buffer.writeu8(out, di + 3, PAD_BYTE)
1403
1389
  end
1404
- return table.concat(parts, "")
1390
+ return buffer.tostring(out)
1405
1391
  end
1406
1392
  local function readPixelsTiled(img, w, h)
1407
1393
  local BYTES_PER_PIXEL = 4
@@ -4019,7 +4005,6 @@ local _binding = Utils
4019
4005
  local getInstanceByPath = _binding.getInstanceByPath
4020
4006
  local Selection = game:GetService("Selection")
4021
4007
  local Workspace = game:GetService("Workspace")
4022
- local RENDER_ORIGIN = Vector3.new(100000, 100000, 100000)
4023
4008
  local DEFAULT_PADDING = 1.35
4024
4009
  local DEFAULT_BACKDROP_COLOR = Color3.fromRGB(0, 255, 0)
4025
4010
  local function getCameraPreset(rawPreset)
@@ -4064,47 +4049,14 @@ local function getCameraDirection(preset)
4064
4049
  end
4065
4050
  return Vector3.new(-1, 0.7, 1).Unit
4066
4051
  end
4067
- local function collectBaseParts(root)
4068
- local parts = {}
4069
- if root:IsA("BasePart") then
4070
- local _root = root
4071
- table.insert(parts, _root)
4072
- end
4073
- for _, desc in root:GetDescendants() do
4074
- if desc:IsA("BasePart") then
4075
- table.insert(parts, desc)
4076
- end
4077
- end
4078
- return parts
4079
- end
4080
- local function createRenderableClone(target, stage)
4052
+ local function getBoundingInfo(target)
4081
4053
  if target:IsA("Model") then
4082
- local clone = target:Clone()
4083
- clone.Parent = stage
4084
- clone:PivotTo(CFrame.new(RENDER_ORIGIN))
4085
- return clone
4086
- end
4087
- if target:IsA("BasePart") then
4088
- local wrapper = Instance.new("Model")
4089
- wrapper.Name = target.Name
4090
- wrapper.Parent = stage
4091
- local clone = target:Clone()
4092
- clone.Parent = wrapper
4093
- clone.Position = RENDER_ORIGIN
4094
- return wrapper
4054
+ return target:GetBoundingBox()
4095
4055
  end
4096
- return nil
4056
+ local part = target
4057
+ return { part.CFrame, part.Size }
4097
4058
  end
4098
- local function styleBaseParts(parts)
4099
- for _, part in parts do
4100
- part.Anchored = true
4101
- part.CanCollide = false
4102
- part.CanTouch = false
4103
- part.CanQuery = false
4104
- part.CastShadow = false
4105
- end
4106
- end
4107
- local function createBackdrop(parent, focusPosition, cameraDirection, radius, cameraDistance, backdropColor, fieldOfView, aspectRatio)
4059
+ local function createBackdrop(focusPosition, cameraDirection, radius, cameraDistance, backdropColor, fieldOfView, aspectRatio)
4108
4060
  local frustumDistance = cameraDistance + radius + 10
4109
4061
  local backdropHeight = math.max(radius * 6, 2 * math.tan(math.rad(fieldOfView) / 2) * frustumDistance * 1.2)
4110
4062
  local backdropWidth = math.max(radius * 6, backdropHeight * aspectRatio)
@@ -4122,7 +4074,7 @@ local function createBackdrop(parent, focusPosition, cameraDirection, radius, ca
4122
4074
  local _cameraDirection = cameraDirection
4123
4075
  local _arg0 = radius + 6
4124
4076
  backdrop.CFrame = CFrame.lookAt(_focusPosition - (_cameraDirection * _arg0), focusPosition)
4125
- backdrop.Parent = parent
4077
+ backdrop.Parent = Workspace
4126
4078
  return backdrop
4127
4079
  end
4128
4080
  local function renderModelScreenshot(requestData)
@@ -4162,32 +4114,18 @@ local function renderModelScreenshot(requestData)
4162
4114
  local previousCameraFocus = currentCamera.Focus
4163
4115
  local previousFieldOfView = currentCamera.FieldOfView
4164
4116
  local previousCameraSubject = currentCamera.CameraSubject
4165
- local stage
4117
+ local backdrop
4166
4118
  local ok, result = pcall(function()
4167
- stage = Instance.new("Model")
4168
- stage.Name = "_MCPRenderStage"
4169
- stage.Parent = Workspace
4170
- local renderRoot = createRenderableClone(target, stage)
4171
- if not renderRoot then
4172
- return {
4173
- error = "Failed to clone render target",
4174
- }
4175
- end
4176
- local baseParts = collectBaseParts(renderRoot)
4177
- if #baseParts == 0 then
4178
- return {
4179
- error = "Target does not contain any BaseParts to render",
4180
- }
4181
- end
4182
- styleBaseParts(baseParts)
4183
- local boundingBoxCFrame, boundingBoxSize = renderRoot:GetBoundingBox()
4184
- local focusPosition = boundingBoxCFrame.Position
4185
- local radius = math.max(boundingBoxSize.Magnitude / 2, 2)
4119
+ local _binding_1 = getBoundingInfo(target)
4120
+ local boundingCFrame = _binding_1[1]
4121
+ local boundingSize = _binding_1[2]
4122
+ local focusPosition = boundingCFrame.Position
4123
+ local radius = math.max(boundingSize.Magnitude / 2, 2)
4186
4124
  local fieldOfView = if currentCamera.FieldOfView > 0 then currentCamera.FieldOfView else 70
4187
4125
  local aspectRatio = if currentCamera.ViewportSize.Y > 0 then currentCamera.ViewportSize.X / currentCamera.ViewportSize.Y else 16 / 9
4188
4126
  local cameraDirection = getCameraDirection(cameraPreset)
4189
4127
  local cameraDistance = math.max(12, (radius * padding) / math.tan(math.rad(fieldOfView) / 2))
4190
- createBackdrop(stage, focusPosition, cameraDirection, radius, cameraDistance, backdropColor, fieldOfView, aspectRatio)
4128
+ backdrop = createBackdrop(focusPosition, cameraDirection, radius, cameraDistance, backdropColor, fieldOfView, aspectRatio)
4191
4129
  pcall(function()
4192
4130
  Selection:Set({})
4193
4131
  end)
@@ -4208,20 +4146,20 @@ local function renderModelScreenshot(requestData)
4208
4146
  return captureResult
4209
4147
  end)
4210
4148
  pcall(function()
4211
- currentCamera.CameraType = previousCameraType
4212
4149
  currentCamera.CFrame = previousCameraCFrame
4213
4150
  currentCamera.Focus = previousCameraFocus
4214
4151
  currentCamera.FieldOfView = previousFieldOfView
4215
4152
  if previousCameraSubject then
4216
4153
  currentCamera.CameraSubject = previousCameraSubject
4217
4154
  end
4155
+ currentCamera.CameraType = previousCameraType
4218
4156
  end)
4219
4157
  pcall(function()
4220
4158
  Selection:Set(previousSelection)
4221
4159
  end)
4222
- if stage then
4160
+ if backdrop then
4223
4161
  pcall(function()
4224
- stage:Destroy()
4162
+ backdrop:Destroy()
4225
4163
  end)
4226
4164
  end
4227
4165
  if not ok then
@@ -4476,17 +4414,18 @@ local function setScriptSource(requestData)
4476
4414
  error = `Failed to set script source. UpdateSourceAsync failed: {updateResult}. Direct assignment failed: {directResult}. Replace method failed: {replaceResult}`,
4477
4415
  }
4478
4416
  end
4417
+ local escapeLuaPattern, escapeLuaReplacement
4479
4418
  local function editScriptLines(requestData)
4480
4419
  local instancePath = requestData.instancePath
4481
- local startLine = requestData.startLine
4482
- local endLine = requestData.endLine
4483
- local newContent = requestData.newContent
4484
- if not (instancePath ~= "" and instancePath) or not (startLine ~= 0 and startLine == startLine and startLine) or not (endLine ~= 0 and endLine == endLine and endLine) or not (newContent ~= "" and newContent) then
4420
+ local oldString = requestData.old_string
4421
+ local newString = requestData.new_string
4422
+ if not (instancePath ~= "" and instancePath) or oldString == nil or newString == nil then
4485
4423
  return {
4486
- error = "Instance path, startLine, endLine, and newContent are required",
4424
+ error = "Instance path, old_string, and new_string are required",
4487
4425
  }
4488
4426
  end
4489
- newContent = normalizeEscapes(newContent)
4427
+ oldString = normalizeEscapes(oldString)
4428
+ newString = normalizeEscapes(newString)
4490
4429
  local instance = getInstanceByPath(instancePath)
4491
4430
  if not instance then
4492
4431
  return {
@@ -4498,68 +4437,41 @@ local function editScriptLines(requestData)
4498
4437
  error = `Instance is not a script-like object: {instance.ClassName}`,
4499
4438
  }
4500
4439
  end
4501
- local recordingId = beginRecording(`Edit script lines {startLine}-{endLine}: {instance.Name}`)
4440
+ local recordingId = beginRecording(`Edit script: {instance.Name}`)
4502
4441
  local success, result = pcall(function()
4503
- local lines, hadTrailingNewline = splitLines(readScriptSource(instance))
4504
- local totalLines = #lines
4505
- if startLine < 1 or startLine > totalLines then
4506
- error(`startLine out of range (1-{totalLines})`)
4507
- end
4508
- if endLine < startLine or endLine > totalLines then
4509
- error(`endLine out of range ({startLine}-{totalLines})`)
4510
- end
4511
- local newLines = splitLines(newContent)
4512
- local resultLines = {}
4513
- do
4514
- local i = 0
4515
- local _shouldIncrement = false
4516
- while true do
4517
- if _shouldIncrement then
4518
- i += 1
4519
- else
4520
- _shouldIncrement = true
4521
- end
4522
- if not (i < startLine - 1) then
4523
- break
4524
- end
4525
- local _arg0 = lines[i + 1]
4526
- table.insert(resultLines, _arg0)
4442
+ local source = readScriptSource(instance)
4443
+ -- Count occurrences to ensure uniqueness
4444
+ local count = 0
4445
+ local searchPos = 1
4446
+ local searchLen = #oldString
4447
+ while true do
4448
+ local foundStart = string.find(source, oldString, searchPos, true)
4449
+ if foundStart == nil then
4450
+ break
4451
+ end
4452
+ count += 1
4453
+ if count > 1 then
4454
+ break
4527
4455
  end
4456
+ searchPos = foundStart + searchLen
4528
4457
  end
4529
- for _, line in newLines do
4530
- table.insert(resultLines, line)
4458
+ if count == 0 then
4459
+ error("old_string not found in script")
4531
4460
  end
4532
- do
4533
- local i = endLine
4534
- local _shouldIncrement = false
4535
- while true do
4536
- if _shouldIncrement then
4537
- i += 1
4538
- else
4539
- _shouldIncrement = true
4540
- end
4541
- if not (i < totalLines) then
4542
- break
4543
- end
4544
- local _arg0 = lines[i + 1]
4545
- table.insert(resultLines, _arg0)
4546
- end
4461
+ if count > 1 then
4462
+ error("old_string matches multiple locations. Provide more surrounding context to make it unique")
4547
4463
  end
4548
- local newSource = joinLines(resultLines, hadTrailingNewline)
4464
+ -- Perform the replacement (plain literal find + replace)
4465
+ local escaped = escapeLuaPattern(oldString)
4466
+ local escapedRepl = escapeLuaReplacement(newString)
4467
+ local newSource = string.gsub(source, escaped, escapedRepl, 1)
4549
4468
  ScriptEditorService:UpdateSourceAsync(instance, function()
4550
4469
  return newSource
4551
4470
  end)
4552
4471
  return {
4553
4472
  success = true,
4554
4473
  instancePath = instancePath,
4555
- editedLines = {
4556
- startLine = startLine,
4557
- endLine = endLine,
4558
- },
4559
- linesRemoved = endLine - startLine + 1,
4560
- linesAdded = #newLines,
4561
- newLineCount = #resultLines,
4562
- message = "Script lines edited successfully",
4474
+ message = "Script edited successfully",
4563
4475
  }
4564
4476
  end)
4565
4477
  if success then
@@ -4568,7 +4480,7 @@ local function editScriptLines(requestData)
4568
4480
  end
4569
4481
  finishRecording(recordingId, false)
4570
4482
  return {
4571
- error = `Failed to edit script lines: {result}`,
4483
+ error = `Failed to edit script: {result}`,
4572
4484
  }
4573
4485
  end
4574
4486
  local function insertScriptLines(requestData)
@@ -4750,10 +4662,10 @@ local function deleteScriptLines(requestData)
4750
4662
  error = `Failed to delete script lines: {result}`,
4751
4663
  }
4752
4664
  end
4753
- local function escapeLuaPattern(s)
4665
+ function escapeLuaPattern(s)
4754
4666
  return (string.gsub(s, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
4755
4667
  end
4756
- local function escapeLuaReplacement(s)
4668
+ function escapeLuaReplacement(s)
4757
4669
  return (string.gsub(s, "%%", "%%%%"))
4758
4670
  end
4759
4671
  local function caseInsensitiveLiteralReplace(src, searchStr, repl)
@@ -5043,6 +4955,7 @@ local function cleanupStopListener()
5043
4955
  end
5044
4956
  local function startPlaytest(requestData)
5045
4957
  local mode = requestData.mode
4958
+ local numPlayers = requestData.numPlayers
5046
4959
  if mode ~= "play" and mode ~= "run" then
5047
4960
  return {
5048
4961
  error = 'mode must be "play" or "run"',
@@ -5092,6 +5005,10 @@ local function startPlaytest(requestData)
5092
5005
  if not injected then
5093
5006
  warn(`[MCP] Failed to inject stop listener: {injErr}`)
5094
5007
  end
5008
+ if numPlayers ~= nil and mode == "run" then
5009
+ local TestService = game:GetService("TestService")
5010
+ TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
5011
+ end
5095
5012
  task.spawn(function()
5096
5013
  local ok, result = pcall(function()
5097
5014
  if mode == "play" then
@@ -5111,9 +5028,10 @@ local function startPlaytest(requestData)
5111
5028
  testRunning = false
5112
5029
  cleanupStopListener()
5113
5030
  end)
5031
+ local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s)` else `Playtest started in {mode} mode`
5114
5032
  return {
5115
5033
  success = true,
5116
- message = `Playtest started in {mode} mode`,
5034
+ message = msg,
5117
5035
  }
5118
5036
  end
5119
5037
  local function stopPlaytest(_requestData)
@@ -5260,7 +5178,7 @@ return {
5260
5178
  <Properties>
5261
5179
  <string name="Name">State</string>
5262
5180
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5263
- local CURRENT_VERSION = "2.6.0-next.0"
5181
+ local CURRENT_VERSION = "2.6.0-next.2"
5264
5182
  local MAX_CONNECTIONS = 5
5265
5183
  local BASE_PORT = 58741
5266
5184
  local activeTabIndex = 0
@@ -5278,6 +5196,7 @@ local function createConnection(port)
5278
5196
  maxRetryDelay = 5,
5279
5197
  retryBackoffMultiplier = 1.2,
5280
5198
  lastHttpOk = false,
5199
+ lastMcpOk = false,
5281
5200
  mcpWaitStartTime = nil,
5282
5201
  isPolling = false,
5283
5202
  heartbeatConnection = nil,
@@ -5288,12 +5207,16 @@ local function addConnection(port)
5288
5207
  if #connections >= MAX_CONNECTIONS then
5289
5208
  return nil
5290
5209
  end
5291
- local lastPort = connections[#connections].port
5292
- local _condition = port
5293
- if _condition == nil then
5294
- _condition = lastPort + 1
5210
+ if port == nil then
5211
+ local maxPort = BASE_PORT - 1
5212
+ for _, conn in connections do
5213
+ if conn.port > maxPort then
5214
+ maxPort = conn.port
5215
+ end
5216
+ end
5217
+ port = maxPort + 1
5295
5218
  end
5296
- local conn = createConnection(_condition)
5219
+ local conn = createConnection(port)
5297
5220
  table.insert(connections, conn)
5298
5221
  return #connections - 1
5299
5222
  end
@@ -5558,6 +5481,15 @@ local function updateTabDot(connIndex)
5558
5481
  tb.dot.BackgroundColor3 = getStatusDotColor(connIndex)
5559
5482
  end
5560
5483
  end
5484
+ local function updateTabLabel(connIndex)
5485
+ local conn = State.getConnection(connIndex)
5486
+ local _tabButtons = tabButtons
5487
+ local _connIndex = connIndex
5488
+ local tb = _tabButtons[_connIndex]
5489
+ if conn and tb and tb.label then
5490
+ tb.label.Text = tostring(conn.port)
5491
+ end
5492
+ end
5561
5493
  local function init(pluginRef)
5562
5494
  local CURRENT_VERSION = State.CURRENT_VERSION
5563
5495
  local screenGui = pluginRef:CreateDockWidgetPluginGuiAsync("MCPServerInterface", DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, false, 300, 260, 260, 200))
@@ -5753,6 +5685,22 @@ local function init(pluginRef)
5753
5685
  urlPadding.PaddingLeft = UDim.new(0, 8)
5754
5686
  urlPadding.PaddingRight = UDim.new(0, 8)
5755
5687
  urlPadding.Parent = urlInput
5688
+ urlInput.FocusLost:Connect(function()
5689
+ local conn = State.getActiveConnection()
5690
+ if not conn or conn.isActive then
5691
+ return nil
5692
+ end
5693
+ conn.serverUrl = urlInput.Text
5694
+ local portStr = string.match(conn.serverUrl, ":(%d+)$")
5695
+ if portStr ~= 0 and portStr == portStr and portStr ~= "" and portStr then
5696
+ local _condition = tonumber(portStr)
5697
+ if _condition == nil then
5698
+ _condition = conn.port
5699
+ end
5700
+ conn.port = _condition
5701
+ end
5702
+ updateTabLabel(State.getActiveTabIndex())
5703
+ end)
5756
5704
  local statusRow = Instance.new("Frame")
5757
5705
  statusRow.Size = UDim2.new(1, 0, 0, 14)
5758
5706
  statusRow.BackgroundTransparency = 1
@@ -5915,29 +5863,7 @@ function updateUIState()
5915
5863
  return nil
5916
5864
  end
5917
5865
  local el = elements
5918
- if conn.isActive then
5919
- el.statusLabel.Text = "Connecting..."
5920
- el.statusLabel.TextColor3 = C.yellow
5921
- el.statusIndicator.BackgroundColor3 = C.yellow
5922
- el.statusPulse.BackgroundColor3 = C.yellow
5923
- el.statusText.Text = "CONNECTING"
5924
- el.detailStatusLabel.Text = if conn.consecutiveFailures == 0 then "..." else "HTTP: X MCP: X"
5925
- el.detailStatusLabel.TextColor3 = C.muted
5926
- startPulseAnimation()
5927
- el.step1Dot.BackgroundColor3 = C.yellow
5928
- el.step1Label.Text = "HTTP server (connecting...)"
5929
- el.step2Dot.BackgroundColor3 = C.yellow
5930
- el.step2Label.Text = "MCP bridge (connecting...)"
5931
- el.step3Dot.BackgroundColor3 = C.yellow
5932
- el.step3Label.Text = "Commands (connecting...)"
5933
- conn.mcpWaitStartTime = nil
5934
- el.troubleshootLabel.Visible = false
5935
- if not buttonHover then
5936
- setButtonDisconnect(el.connectButton, el.connectStroke)
5937
- end
5938
- el.urlInput.TextEditable = false
5939
- el.urlInput.BackgroundColor3 = C.card
5940
- else
5866
+ if not conn.isActive then
5941
5867
  el.statusLabel.Text = "Disconnected"
5942
5868
  el.statusLabel.TextColor3 = C.muted
5943
5869
  el.statusIndicator.BackgroundColor3 = C.red
@@ -5952,13 +5878,101 @@ function updateUIState()
5952
5878
  el.step2Label.Text = "MCP bridge"
5953
5879
  el.step3Dot.BackgroundColor3 = C.gray
5954
5880
  el.step3Label.Text = "Commands"
5955
- conn.mcpWaitStartTime = nil
5956
5881
  el.troubleshootLabel.Visible = false
5957
5882
  if not buttonHover then
5958
5883
  setButtonConnect(el.connectButton, el.connectStroke)
5959
5884
  end
5960
5885
  el.urlInput.TextEditable = true
5961
5886
  el.urlInput.BackgroundColor3 = C.bg
5887
+ return nil
5888
+ end
5889
+ if not buttonHover then
5890
+ setButtonDisconnect(el.connectButton, el.connectStroke)
5891
+ end
5892
+ el.urlInput.TextEditable = false
5893
+ el.urlInput.BackgroundColor3 = C.card
5894
+ if conn.lastHttpOk and conn.lastMcpOk then
5895
+ el.statusLabel.Text = "Connected"
5896
+ el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
5897
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
5898
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
5899
+ el.statusText.Text = "ONLINE"
5900
+ el.detailStatusLabel.Text = "HTTP: OK MCP: OK"
5901
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
5902
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
5903
+ el.step1Label.Text = "HTTP server (OK)"
5904
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
5905
+ el.step2Label.Text = "MCP bridge (OK)"
5906
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
5907
+ el.step3Label.Text = "Commands (OK)"
5908
+ el.troubleshootLabel.Visible = false
5909
+ stopPulseAnimation()
5910
+ elseif conn.lastHttpOk and not conn.lastMcpOk then
5911
+ el.statusLabel.Text = "Waiting for MCP server"
5912
+ el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
5913
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5914
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5915
+ el.statusText.Text = "WAITING"
5916
+ el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
5917
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
5918
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
5919
+ el.step1Label.Text = "HTTP server (OK)"
5920
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5921
+ el.step2Label.Text = "MCP bridge (waiting...)"
5922
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5923
+ el.step3Label.Text = "Commands (waiting...)"
5924
+ local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
5925
+ el.troubleshootLabel.Visible = elapsed > 8
5926
+ startPulseAnimation()
5927
+ elseif conn.consecutiveFailures >= conn.maxFailuresBeforeError then
5928
+ el.statusLabel.Text = "Server unavailable"
5929
+ el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
5930
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
5931
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
5932
+ el.statusText.Text = "ERROR"
5933
+ el.detailStatusLabel.Text = "HTTP: X MCP: X"
5934
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
5935
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
5936
+ el.step1Label.Text = "HTTP server (error)"
5937
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
5938
+ el.step2Label.Text = "MCP bridge (error)"
5939
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
5940
+ el.step3Label.Text = "Commands (error)"
5941
+ el.troubleshootLabel.Visible = false
5942
+ stopPulseAnimation()
5943
+ elseif conn.consecutiveFailures > 5 then
5944
+ local waitTime = math.ceil(conn.currentRetryDelay)
5945
+ el.statusLabel.Text = `Retrying ({waitTime}s)`
5946
+ el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
5947
+ el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5948
+ el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5949
+ el.statusText.Text = "RETRY"
5950
+ el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
5951
+ el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
5952
+ el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5953
+ el.step1Label.Text = "HTTP server (retrying...)"
5954
+ el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5955
+ el.step2Label.Text = "MCP bridge (retrying...)"
5956
+ el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
5957
+ el.step3Label.Text = "Commands (retrying...)"
5958
+ el.troubleshootLabel.Visible = false
5959
+ startPulseAnimation()
5960
+ else
5961
+ el.statusLabel.Text = if conn.consecutiveFailures > 1 then `Connecting (attempt {conn.consecutiveFailures})` else "Connecting..."
5962
+ el.statusLabel.TextColor3 = C.yellow
5963
+ el.statusIndicator.BackgroundColor3 = C.yellow
5964
+ el.statusPulse.BackgroundColor3 = C.yellow
5965
+ el.statusText.Text = "CONNECTING"
5966
+ el.detailStatusLabel.Text = if conn.consecutiveFailures == 0 then "..." else "HTTP: ... MCP: ..."
5967
+ el.detailStatusLabel.TextColor3 = C.muted
5968
+ el.step1Dot.BackgroundColor3 = C.yellow
5969
+ el.step1Label.Text = "HTTP server (connecting...)"
5970
+ el.step2Dot.BackgroundColor3 = C.yellow
5971
+ el.step2Label.Text = "MCP bridge (connecting...)"
5972
+ el.step3Dot.BackgroundColor3 = C.yellow
5973
+ el.step3Label.Text = "Commands (connecting...)"
5974
+ el.troubleshootLabel.Visible = false
5975
+ startPulseAnimation()
5962
5976
  end
5963
5977
  end
5964
5978
  return {
@@ -5966,6 +5980,7 @@ return {
5966
5980
  init = init,
5967
5981
  updateUIState = updateUIState,
5968
5982
  updateTabDot = updateTabDot,
5983
+ updateTabLabel = updateTabLabel,
5969
5984
  stopPulseAnimation = stopPulseAnimation,
5970
5985
  startPulseAnimation = startPulseAnimation,
5971
5986
  getElements = function()
@@ -19,9 +19,9 @@ const instanceId = HttpService.GenerateGUID(false);
19
19
  let assignedRole: string | undefined;
20
20
 
21
21
  function detectRole(): string {
22
- if (RunService.IsServer() && RunService.IsRunMode()) return "server";
23
- if (RunService.IsClient()) return "client";
24
- return "edit";
22
+ if (!RunService.IsRunMode()) return "edit";
23
+ if (RunService.IsServer()) return "server";
24
+ return "client";
25
25
  }
26
26
 
27
27
  type Handler = (data: Record<string, unknown>) => unknown;
@@ -192,19 +192,6 @@ function pollForRequests(connIndex: number) {
192
192
  }
193
193
  const elapsed = tick() - (conn.mcpWaitStartTime ?? tick());
194
194
  el.troubleshootLabel.Visible = elapsed > 8;
195
- if (elapsed > 3 && elapsed % 5 < conn.pollInterval) {
196
- task.spawn(() => {
197
- const discovered = discoverPort(getUsedPorts(connIndex));
198
- if (discovered !== undefined && discovered !== conn.port) {
199
- conn.port = discovered;
200
- conn.serverUrl = `http://localhost:${discovered}`;
201
- UI.updateTabLabel(connIndex);
202
- if (connIndex === State.getActiveTabIndex()) {
203
- UI.getElements().urlInput.Text = conn.serverUrl;
204
- }
205
- }
206
- });
207
- }
208
195
  UI.startPulseAnimation();
209
196
  }
210
197
  }
@@ -229,21 +216,6 @@ function pollForRequests(connIndex: number) {
229
216
  );
230
217
  }
231
218
 
232
- if (conn.consecutiveFailures === 5 || conn.consecutiveFailures % 20 === 0) {
233
- task.spawn(() => {
234
- const discovered = discoverPort(getUsedPorts(connIndex));
235
- if (discovered !== undefined && discovered !== conn.port) {
236
- conn.port = discovered;
237
- conn.serverUrl = `http://localhost:${discovered}`;
238
- conn.consecutiveFailures = 0;
239
- conn.currentRetryDelay = 0.5;
240
- UI.updateTabLabel(connIndex);
241
- if (connIndex === State.getActiveTabIndex()) {
242
- UI.getElements().urlInput.Text = conn.serverUrl;
243
- }
244
- }
245
- });
246
- }
247
219
 
248
220
  if (connIndex === State.getActiveTabIndex()) {
249
221
  const el = ui;
@@ -304,53 +276,6 @@ function pollForRequests(connIndex: number) {
304
276
  }
305
277
  }
306
278
 
307
- function getUsedPorts(excludeIndex: number): Set<number> {
308
- const ports = new Set<number>();
309
- const conns = State.getConnections();
310
- for (let i = 0; i < conns.size(); i++) {
311
- if (i !== excludeIndex && conns[i].isActive) {
312
- ports.add(conns[i].port);
313
- }
314
- }
315
- return ports;
316
- }
317
-
318
- function checkPort(port: number): boolean {
319
- const [ok, res] = pcall(() => {
320
- return HttpService.RequestAsync({
321
- Url: `http://localhost:${port}/status`,
322
- Method: "GET",
323
- Headers: { "Content-Type": "application/json" },
324
- });
325
- });
326
- return ok && res.Success;
327
- }
328
-
329
- function discoverPort(excludePorts?: Set<number>): number | undefined {
330
- let firstActivePort: number | undefined;
331
- for (let offset = 0; offset < 5; offset++) {
332
- const port = State.BASE_PORT + offset;
333
- if (excludePorts && excludePorts.has(port)) continue;
334
- const [success, result] = pcall(() => {
335
- return HttpService.RequestAsync({
336
- Url: `http://localhost:${port}/status`,
337
- Method: "GET",
338
- Headers: { "Content-Type": "application/json" },
339
- });
340
- });
341
-
342
- if (success && result.Success) {
343
- const [ok, data] = pcall(() =>
344
- HttpService.JSONDecode(result.Body) as { mcpServerActive: boolean; pluginConnected: boolean },
345
- );
346
- if (ok && data.mcpServerActive) {
347
- if (!data.pluginConnected) return port;
348
- if (firstActivePort === undefined) firstActivePort = port;
349
- }
350
- }
351
- }
352
- return firstActivePort;
353
- }
354
279
 
355
280
  function activatePlugin(connIndex?: number) {
356
281
  const idx = connIndex ?? State.getActiveTabIndex();
@@ -374,19 +299,6 @@ function activatePlugin(connIndex?: number) {
374
299
  UI.updateTabDot(idx);
375
300
 
376
301
  task.spawn(() => {
377
- if (!checkPort(conn.port)) {
378
- const usedPorts = getUsedPorts(idx);
379
- const discoveredPort = discoverPort(usedPorts);
380
- if (discoveredPort !== undefined) {
381
- conn.port = discoveredPort;
382
- conn.serverUrl = `http://localhost:${discoveredPort}`;
383
- UI.updateTabLabel(idx);
384
- if (idx === State.getActiveTabIndex()) {
385
- ui.urlInput.Text = conn.serverUrl;
386
- }
387
- }
388
- }
389
-
390
302
  if (!conn.heartbeatConnection) {
391
303
  conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
392
304
  const now = tick();
@@ -193,15 +193,15 @@ function setScriptSource(requestData: Record<string, unknown>) {
193
193
 
194
194
  function editScriptLines(requestData: Record<string, unknown>) {
195
195
  const instancePath = requestData.instancePath as string;
196
- const startLine = requestData.startLine as number;
197
- const endLine = requestData.endLine as number;
198
- let newContent = requestData.newContent as string;
196
+ let oldString = requestData.old_string as string;
197
+ let newString = requestData.new_string as string;
199
198
 
200
- if (!instancePath || !startLine || !endLine || !newContent) {
201
- return { error: "Instance path, startLine, endLine, and newContent are required" };
199
+ if (!instancePath || oldString === undefined || newString === undefined) {
200
+ return { error: "Instance path, old_string, and new_string are required" };
202
201
  }
203
202
 
204
- newContent = normalizeEscapes(newContent);
203
+ oldString = normalizeEscapes(oldString);
204
+ newString = normalizeEscapes(newString);
205
205
 
206
206
  const instance = getInstanceByPath(instancePath);
207
207
  if (!instance) return { error: `Instance not found: ${instancePath}` };
@@ -209,32 +209,38 @@ function editScriptLines(requestData: Record<string, unknown>) {
209
209
  return { error: `Instance is not a script-like object: ${instance.ClassName}` };
210
210
  }
211
211
 
212
- const recordingId = beginRecording(`Edit script lines ${startLine}-${endLine}: ${instance.Name}`);
212
+ const recordingId = beginRecording(`Edit script: ${instance.Name}`);
213
213
 
214
214
  const [success, result] = pcall(() => {
215
- const [lines, hadTrailingNewline] = splitLines(readScriptSource(instance));
216
- const totalLines = lines.size();
217
-
218
- if (startLine < 1 || startLine > totalLines) error(`startLine out of range (1-${totalLines})`);
219
- if (endLine < startLine || endLine > totalLines) error(`endLine out of range (${startLine}-${totalLines})`);
215
+ const source = readScriptSource(instance);
216
+
217
+ // Count occurrences to ensure uniqueness
218
+ let count = 0;
219
+ let searchPos = 1;
220
+ const searchLen = oldString.size();
221
+
222
+ while (true) {
223
+ const [foundStart] = string.find(source, oldString, searchPos, true);
224
+ if (foundStart === undefined) break;
225
+ count++;
226
+ if (count > 1) break;
227
+ searchPos = foundStart + searchLen;
228
+ }
220
229
 
221
- const [newLines] = splitLines(newContent);
222
- const resultLines: string[] = [];
230
+ if (count === 0) error("old_string not found in script");
231
+ if (count > 1) error("old_string matches multiple locations. Provide more surrounding context to make it unique");
223
232
 
224
- for (let i = 0; i < startLine - 1; i++) resultLines.push(lines[i]);
225
- for (const line of newLines) resultLines.push(line);
226
- for (let i = endLine; i < totalLines; i++) resultLines.push(lines[i]);
233
+ // Perform the replacement (plain literal find + replace)
234
+ const escaped = escapeLuaPattern(oldString);
235
+ const escapedRepl = escapeLuaReplacement(newString);
236
+ const [newSource] = string.gsub(source, escaped, escapedRepl, 1);
227
237
 
228
- const newSource = joinLines(resultLines, hadTrailingNewline);
229
238
  ScriptEditorService.UpdateSourceAsync(instance, () => newSource);
230
239
 
231
240
  return {
232
- success: true, instancePath,
233
- editedLines: { startLine, endLine },
234
- linesRemoved: endLine - startLine + 1,
235
- linesAdded: newLines.size(),
236
- newLineCount: resultLines.size(),
237
- message: "Script lines edited successfully",
241
+ success: true,
242
+ instancePath,
243
+ message: "Script edited successfully",
238
244
  };
239
245
  });
240
246
 
@@ -243,7 +249,7 @@ function editScriptLines(requestData: Record<string, unknown>) {
243
249
  return result;
244
250
  }
245
251
  finishRecording(recordingId, false);
246
- return { error: `Failed to edit script lines: ${result}` };
252
+ return { error: `Failed to edit script: ${result}` };
247
253
  }
248
254
 
249
255
  function insertScriptLines(requestData: Record<string, unknown>) {