@el4cteo/rbx-studio-mcp 0.1.3 → 0.1.5
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 +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/handlers/Debug.luau +56 -1
- package/plugin/src/handlers/Session.luau +169 -168
- package/scripts/release-notes.mjs +43 -0
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { registerInputTools } from "./tools/input.js";
|
|
|
20
20
|
import { registerDeviceTools } from "./tools/device.js";
|
|
21
21
|
import { registerApiTools } from "./tools/api.js";
|
|
22
22
|
import { registerResources } from "./resources.js";
|
|
23
|
-
const VERSION = "0.1.
|
|
23
|
+
const VERSION = "0.1.5";
|
|
24
24
|
function parsePort(argv) {
|
|
25
25
|
const flag = argv.indexOf("--port");
|
|
26
26
|
const raw = flag !== -1 ? argv[flag + 1] : process.env["ROBLOX_STUDIO_MCP_PORT"];
|
package/package.json
CHANGED
package/plugin/src/Config.luau
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
local Config = {}
|
|
11
11
|
|
|
12
|
-
Config.PLUGIN_VERSION = "0.1.
|
|
12
|
+
Config.PLUGIN_VERSION = "0.1.5"
|
|
13
13
|
|
|
14
14
|
-- Fingerprint of plugin/src, stamped in by scripts/build-plugin.mjs. The server
|
|
15
15
|
-- computes the same hash from its own copy of the sources and compares, so a
|
|
@@ -42,7 +42,28 @@
|
|
|
42
42
|
is why nothing in this file offers one.
|
|
43
43
|
]]
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
--[[
|
|
46
|
+
Fetched defensively, because this module is required at plugin start.
|
|
47
|
+
|
|
48
|
+
Measured with the beta off and Studio restarted: `GetService` does NOT throw
|
|
49
|
+
here, it hands back a working service object. So this guard is not fixing an
|
|
50
|
+
observed crash -- it is insurance on the one call in this file that runs
|
|
51
|
+
before any tool is invoked. init.server.luau requires this module alongside
|
|
52
|
+
every other handler, so a throw at this line would take down all 29 tools
|
|
53
|
+
rather than the three that need a debugger, and it would do it silently: a
|
|
54
|
+
plugin that never loads never connects, leaving nothing to read anywhere.
|
|
55
|
+
|
|
56
|
+
The beta is NOT detected here, because nothing detects it cheaply. See the
|
|
57
|
+
note on the wholesale-refusal branch in `Debug.set` for the two probes that
|
|
58
|
+
were tried and why neither works.
|
|
59
|
+
]]
|
|
60
|
+
local ScriptDebuggerService: any = nil
|
|
61
|
+
do
|
|
62
|
+
local ok, service = pcall(game.GetService, game, "ScriptDebuggerService")
|
|
63
|
+
if ok then
|
|
64
|
+
ScriptDebuggerService = service
|
|
65
|
+
end
|
|
66
|
+
end
|
|
46
67
|
|
|
47
68
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
48
69
|
local Paths = require(script.Parent.Parent.Paths)
|
|
@@ -190,6 +211,11 @@ local function install(): boolean
|
|
|
190
211
|
return true
|
|
191
212
|
end
|
|
192
213
|
|
|
214
|
+
if ScriptDebuggerService == nil then
|
|
215
|
+
installError = "the service is not registered in this Studio"
|
|
216
|
+
return false
|
|
217
|
+
end
|
|
218
|
+
|
|
193
219
|
local ok, err = pcall(function()
|
|
194
220
|
service.OnStopped = function(stopped: any): any
|
|
195
221
|
-- Capture must never be what keeps a thread paused.
|
|
@@ -297,6 +323,35 @@ function Debug.set(params: { [string]: any }): { [string]: any }
|
|
|
297
323
|
end
|
|
298
324
|
end
|
|
299
325
|
|
|
326
|
+
--[[
|
|
327
|
+
A wholesale refusal names the likely cause without asserting it.
|
|
328
|
+
|
|
329
|
+
`AddBreakpoint` is where the "Debugger Luau API" beta actually bites, and
|
|
330
|
+
its own message -- "Failed to execute AddBreakpoint request" -- names
|
|
331
|
+
nothing anyone can act on. The beta cannot be detected from here to say
|
|
332
|
+
so with certainty, and two attempts to are worth recording so they are
|
|
333
|
+
not tried a third time: with the beta OFF, `GetService`, `FindService`,
|
|
334
|
+
assigning `OnStopped` and `Enum.DebuggerResumeType` all still succeed;
|
|
335
|
+
with the beta ON, `ReflectionService` still does not list the class. The
|
|
336
|
+
first is always available and the second never is, so neither varies with
|
|
337
|
+
the thing being measured.
|
|
338
|
+
|
|
339
|
+
The only reliable test is this call, which cannot be run speculatively on
|
|
340
|
+
a user's script just to answer a status question. So the hint says what
|
|
341
|
+
to check first and what else it could be, and does not claim to know.
|
|
342
|
+
]]
|
|
343
|
+
if #added == 0 and #failed > 0 then
|
|
344
|
+
Dispatch.fail(
|
|
345
|
+
"NO_BREAKPOINTS_SET",
|
|
346
|
+
string.format("No breakpoint could be set; all %d were refused.", #failed),
|
|
347
|
+
"Check that \"Debugger Luau API\" is on in File > Beta Features and that "
|
|
348
|
+
.. "Studio has been restarted since -- that is the usual cause, it is "
|
|
349
|
+
.. "off by default, and only the user can change it. Otherwise the "
|
|
350
|
+
.. "line may not be one that runs: a `return`, an `end` or a bare "
|
|
351
|
+
.. "declaration is often refused, so try the statement above it."
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
300
355
|
return { added = added, failed = failed, installed = installed }
|
|
301
356
|
end
|
|
302
357
|
|
|
@@ -1,168 +1,169 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Session and connectivity handlers. `studio.status` is the cheapest call in the
|
|
4
|
-
system and doubles as the liveness probe, so it must never touch anything
|
|
5
|
-
expensive -- notably it counts descendants lazily and caps the selection.
|
|
6
|
-
]]
|
|
7
|
-
|
|
8
|
-
local MarketplaceService = game:GetService("MarketplaceService")
|
|
9
|
-
local RunService = game:GetService("RunService")
|
|
10
|
-
local Selection = game:GetService("Selection")
|
|
11
|
-
|
|
12
|
-
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
13
|
-
local Context = require(script.Parent.Parent.Context)
|
|
14
|
-
local Editor = require(script.Parent.Parent.Editor)
|
|
15
|
-
local Emulation = require(script.Parent.Parent.Emulation)
|
|
16
|
-
local Paths = require(script.Parent.Parent.Paths)
|
|
17
|
-
|
|
18
|
-
-- `version()` is marked deprecated but remains the only way to read the Studio
|
|
19
|
-
-- build, and the agent needs it to know which engine APIs actually exist in this
|
|
20
|
-
-- session. Bound indirectly so the deprecation lint stays enabled file-wide.
|
|
21
|
-
local studioVersion: () -> string = (version :: any)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
--
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
--
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
local
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
local
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
local
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
to
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
local
|
|
83
|
-
local
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
--
|
|
123
|
-
--
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
--
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
--
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Session and connectivity handlers. `studio.status` is the cheapest call in the
|
|
4
|
+
system and doubles as the liveness probe, so it must never touch anything
|
|
5
|
+
expensive -- notably it counts descendants lazily and caps the selection.
|
|
6
|
+
]]
|
|
7
|
+
|
|
8
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
9
|
+
local RunService = game:GetService("RunService")
|
|
10
|
+
local Selection = game:GetService("Selection")
|
|
11
|
+
|
|
12
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
13
|
+
local Context = require(script.Parent.Parent.Context)
|
|
14
|
+
local Editor = require(script.Parent.Parent.Editor)
|
|
15
|
+
local Emulation = require(script.Parent.Parent.Emulation)
|
|
16
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
17
|
+
|
|
18
|
+
-- `version()` is marked deprecated but remains the only way to read the Studio
|
|
19
|
+
-- build, and the agent needs it to know which engine APIs actually exist in this
|
|
20
|
+
-- session. Bound indirectly so the deprecation lint stays enabled file-wide.
|
|
21
|
+
local studioVersion: () -> string = (version :: any)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
-- Selection can be tens of thousands of instances after a rubber-band drag.
|
|
25
|
+
-- Anything past this is noise for an agent and would blow the response budget.
|
|
26
|
+
local MAX_SELECTION = 50
|
|
27
|
+
|
|
28
|
+
-- Counting descendants of a large place is O(n) over the whole DataModel, so it
|
|
29
|
+
-- is cached briefly; status is polled often enough for this to matter.
|
|
30
|
+
local COUNT_CACHE_SECONDS = 5
|
|
31
|
+
|
|
32
|
+
local cachedCounts: { descendants: number, scripts: number }? = nil
|
|
33
|
+
local cachedAt = 0
|
|
34
|
+
|
|
35
|
+
--[[
|
|
36
|
+
Which device Studio is pretending to be, or nil for none.
|
|
37
|
+
|
|
38
|
+
Reported here because nothing else says it. Device emulation resizes the
|
|
39
|
+
viewport and stays on until it is switched off, so every screenshot after it
|
|
40
|
+
comes back the wrong shape with no indication why -- and a caller who did not
|
|
41
|
+
set it, or who set it twenty calls ago, has no way to find out. `status` is
|
|
42
|
+
the call agents are told to make first, which makes it the right place for a
|
|
43
|
+
piece of session state that silently changes what everything else returns.
|
|
44
|
+
]]
|
|
45
|
+
local function counts(): { descendants: number, scripts: number }
|
|
46
|
+
local now = os.clock()
|
|
47
|
+
local cache = cachedCounts
|
|
48
|
+
if cache and now - cachedAt < COUNT_CACHE_SECONDS then
|
|
49
|
+
return cache
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
local descendants = 0
|
|
53
|
+
local scripts = 0
|
|
54
|
+
for _, instance in game:GetDescendants() do
|
|
55
|
+
descendants += 1
|
|
56
|
+
if instance:IsA("LuaSourceContainer") then
|
|
57
|
+
scripts += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
local fresh = { descendants = descendants, scripts = scripts }
|
|
62
|
+
cachedCounts = fresh
|
|
63
|
+
cachedAt = now
|
|
64
|
+
return fresh
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
--[[
|
|
68
|
+
The name the user would recognise.
|
|
69
|
+
|
|
70
|
+
`game.Name` is the data model's name -- "Place1", "Place5" -- and has nothing
|
|
71
|
+
to do with what the place is called on the Studio tab or the Creator
|
|
72
|
+
Dashboard. Nothing local holds the real name: it lives on Roblox's servers, so
|
|
73
|
+
it takes a lookup. Without it, a user saying "the other place, the published
|
|
74
|
+
one" cannot be matched to any window, which is the whole point of being able
|
|
75
|
+
to drive several Studios at once.
|
|
76
|
+
|
|
77
|
+
Cached, including the failure, because an unpublished place would otherwise
|
|
78
|
+
make a doomed web request on every status call.
|
|
79
|
+
]]
|
|
80
|
+
local NAME_RETRY_SECONDS = 60
|
|
81
|
+
|
|
82
|
+
local resolvedName: string? = nil
|
|
83
|
+
local resolvedFor = -1
|
|
84
|
+
local resolvedAt = 0
|
|
85
|
+
|
|
86
|
+
local function publishedPlaceName(): string?
|
|
87
|
+
local placeId = game.PlaceId
|
|
88
|
+
-- A place that has never been saved to Roblox has no id and no remote name.
|
|
89
|
+
if placeId == 0 then
|
|
90
|
+
return nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
local fresh = resolvedFor == placeId
|
|
94
|
+
and (resolvedName ~= nil or os.clock() - resolvedAt < NAME_RETRY_SECONDS)
|
|
95
|
+
if fresh then
|
|
96
|
+
return resolvedName
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
resolvedFor = placeId
|
|
100
|
+
resolvedAt = os.clock()
|
|
101
|
+
|
|
102
|
+
local ok, info = pcall(function()
|
|
103
|
+
return MarketplaceService:GetProductInfo(placeId, Enum.InfoType.Asset)
|
|
104
|
+
end)
|
|
105
|
+
local name = if ok and typeof(info) == "table" then (info :: any).Name else nil
|
|
106
|
+
resolvedName = if typeof(name) == "string" and name ~= "" then name else nil
|
|
107
|
+
return resolvedName
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local Session = {}
|
|
111
|
+
|
|
112
|
+
function Session.status(): { [string]: any }
|
|
113
|
+
local selected: { { path: string, className: string } } = {}
|
|
114
|
+
for index, instance in Selection:Get() do
|
|
115
|
+
if index > MAX_SELECTION then
|
|
116
|
+
break
|
|
117
|
+
end
|
|
118
|
+
table.insert(selected, { path = Paths.of(instance), className = instance.ClassName })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
local totals = counts()
|
|
122
|
+
-- Open tabs ride along with status rather than getting their own tool: this
|
|
123
|
+
-- is the call agents are told to make first, and "which script is the user
|
|
124
|
+
-- looking at" is orientation, not a separate question.
|
|
125
|
+
local openScripts = Editor.documents()
|
|
126
|
+
|
|
127
|
+
local published = publishedPlaceName()
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
openScripts = if #openScripts > 0 then openScripts else nil,
|
|
131
|
+
placeName = published or (if game.Name ~= "" then game.Name else "Untitled place"),
|
|
132
|
+
-- Kept separate when the two differ, so a path rooted at the data model
|
|
133
|
+
-- name stays explicable next to a place called something else entirely.
|
|
134
|
+
dataModelName = if published and published ~= game.Name then game.Name else nil,
|
|
135
|
+
placeId = game.PlaceId,
|
|
136
|
+
context = Context.of(),
|
|
137
|
+
-- RunService:IsRunning() is true for both Play and Run; IsEdit()
|
|
138
|
+
-- distinguishes the authoring session from a live one.
|
|
139
|
+
isRunning = RunService:IsRunning(),
|
|
140
|
+
isEdit = RunService:IsEdit(),
|
|
141
|
+
isRunMode = RunService:IsRunning() and not RunService:IsClient(),
|
|
142
|
+
isServerView = RunService:IsServer(),
|
|
143
|
+
selection = selected,
|
|
144
|
+
selectionCount = #Selection:Get(),
|
|
145
|
+
descendantCount = totals.descendants,
|
|
146
|
+
scriptCount = totals.scripts,
|
|
147
|
+
studioVersion = studioVersion(),
|
|
148
|
+
emulatedDevice = Emulation.summary(),
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[
|
|
153
|
+
Round-trip probe used by the smoke test and by `studio_status` when the agent
|
|
154
|
+
only needs to know the channel is alive.
|
|
155
|
+
]]
|
|
156
|
+
function Session.ping(params: { [string]: any }): { [string]: any }
|
|
157
|
+
return { pong = true, echo = params.echo }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
function Session.register()
|
|
161
|
+
Dispatch.registerAll("studio", {
|
|
162
|
+
status = function()
|
|
163
|
+
return Session.status()
|
|
164
|
+
end,
|
|
165
|
+
ping = Session.ping,
|
|
166
|
+
})
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
return Session
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulls one version's section out of CHANGELOG.md for the release body.
|
|
3
|
+
*
|
|
4
|
+
* Exists so a release cannot go out without notes. GitHub's own
|
|
5
|
+
* `generate_release_notes` lists commit subjects, which are written for whoever
|
|
6
|
+
* reads the diff and say nothing useful to someone deciding whether to upgrade.
|
|
7
|
+
* Failing the workflow on a missing section is the point: the alternative is a
|
|
8
|
+
* tag that quietly publishes with an empty body, which is what happened for
|
|
9
|
+
* 0.1.1 through 0.1.3.
|
|
10
|
+
*
|
|
11
|
+
* Usage: node scripts/release-notes.mjs <version> <outFile>
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const [, , rawVersion, outFile] = process.argv;
|
|
16
|
+
if (!rawVersion || !outFile) {
|
|
17
|
+
throw new Error("usage: release-notes.mjs <version> <outFile>");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Tags carry a leading v; the changelog headings do not.
|
|
21
|
+
const version = rawVersion.replace(/^v/, "");
|
|
22
|
+
const changelog = readFileSync("CHANGELOG.md", "utf8");
|
|
23
|
+
|
|
24
|
+
// Everything from this version's heading up to the next one, so adding a
|
|
25
|
+
// release never means touching the extractor.
|
|
26
|
+
const heading = new RegExp(`^## ${version.replace(/\./g, "\\.")}\\s*$`, "m");
|
|
27
|
+
const start = changelog.search(heading);
|
|
28
|
+
if (start === -1) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`CHANGELOG.md has no "## ${version}" section. Add one describing what changed ` +
|
|
31
|
+
`before tagging, or the release would publish with an empty body.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const body = changelog.slice(start).split("\n").slice(1).join("\n");
|
|
36
|
+
const next = body.search(/^## /m);
|
|
37
|
+
const section = (next === -1 ? body : body.slice(0, next)).trim();
|
|
38
|
+
|
|
39
|
+
if (section.length === 0) {
|
|
40
|
+
throw new Error(`The "## ${version}" section in CHANGELOG.md is empty.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
writeFileSync(outFile, `${section}\n`);
|