Releases: princeofscale/bloxforge
Release list
BloxForge 4.4.0
An upstream-parity pass against Chrrxs/robloxstudio-mcp v2.22.3..v3.0.5, on top of sixteen merged PRs that had been sitting unreleased since 4.3.1. Tool count 227 → 228; test count 1123 → 1504.
Added
manage_selection — the write half of the selection, plus camera framing. The server could read the selection and never set it, and nothing could point the camera, which made capture_screenshot only as useful as wherever the camera already was. action is set, add, remove or focus; set with an empty paths clears. A path that does not resolve fails the whole call rather than selecting the subset that happened to exist. get_selection keeps its name and its read-only annotation.
Fixed
- A screenshot was shipped from Studio as raw RGBA at native resolution — 44MB in one HTTP body on a 4K display, 176MB on an 8K one, for an image the server downscales to 1568px wide anyway. Studio now bounds that read and downscales before transfer. Click coordinates are unaffected: the logical viewport is read from the camera, not the image.
capture_screenshotcould return an image it had already found too large. The JPEG quality loop stops at q25 whether or not the result fits, and returned the oversized buffer — the case the code's own comment calls catastrophic, because an oversized inline image closes the MCP connection and drops every Studio registration instead of failing gracefully.- Arbitrary Luau was not marked
openWorldHint.execute_luau,execute_luau_async,eval_server_runtimeandeval_client_runtimedeclarestudio.execute, which reads as bounded by the local place — but the code reachesHttpService,MarketplaceServiceand DataStores. Clients gating on the annotation were treating unrestricted execution as local-only. character_navigationnever moved anyone. Navigation was signalled from the edit peer overLogService.MessageOutto an injected Script — reflection that does not cross into the play server. The signal was written, nothing heard it, and the call sat out its timeout. The walk now happens locally in the play DataModel.targetdefaults to"server";"edit"was never a DataModel that could service the call.get_instance_propertieshid properties the write tools can set. Roblox exposes no property enumeration to plugins, so the read is a fixed list — andAnchorPoint,Font,TextScaledandResetOnSpawnwere absent from it, making them writable and then unreadable.- Undo waypoints read
"MCP: MCP: ..."when a caller supplied a label that already carried the prefix.
Changed
get_project_structureomitschildrenon a leaf. A leaf is most of any tree. The matching upstream change that drops an emptyattributesmap fromget_attributesis not taken: it saves two characters and would break a client enumeratingresponse.attributeson the common case.
Full detail, including which upstream commits were triaged as not applicable and why, is in CHANGELOG.md.
v4.3.1
Patch release. The reason to cut it is the CFrame fix: until now a rotation was invisible to BloxForge.
Fixed
A CFrame no longer loses its orientation on the way out. Three read paths reduced it to a position and reported success — mass_get_property/get_attributes answered {"_type":"CFrame","Position":{...}}, get_node_batch answered three numbers byte-identical to that position, and the change fingerprint behind get_changes_since hashed only cf.Position. Verified live: rotating a part from Orientation [20.7,49.1,82.2] to [0,90,0] with its position untouched produced "changed":[] and changedCount: 0 — a visible edit the changefeed could not see at all. Of every type this serializer handles, CFrame was the only one that looked like a complete structured read while half the value was gone.
A serialized CFrame can now be written back. Neither the attribute path nor the property path had a CFrame branch, so the tagged table fell through and was stored as a table.
Added
npm run protocol:compat-check. From MCP revision 2026-07-28 the tool set "MUST NOT vary per-connection or as a side effect of other requests on the connection" — both of which load_toolset does. Every revision the pinned SDK can negotiate is 2025-11-25 or earlier, all of which permit it, so 4.3.1 is compliant with everything it can actually speak. The check fails the build the moment an SDK bump makes a forbidding revision negotiable, so the conflict cannot arrive silently.
Changed
load_toolset says when to switch toolsets. Tool definitions sit at the top of the prompt-cache hierarchy — above system and messages — so changing them invalidates the cached prefix for the entire conversation, not only the schemas that moved. 4.3.0 shipped unload without that caveat, which made churn look free. Switch at phase boundaries, not per call.
The network-effects audit stopped under-counting its own reach. A bare fetch( was not a recognized client, delegation through this.x() was not followed, and five tools with template-literal names were invisible to it. No effect was ever mis-declared — what was wrong is that the audit reported coverage it did not have. 12 → 14 checked, plus a check from the method end that catches definitions the tool-end scan cannot enumerate.
Removed
The ROADMAP-RESEARCH-* working notes are no longer tracked.
Full changelog: v4.3.0...v4.3.1
v4.3.0 — release a toolset, and hold the change baseline still
Two things an agent pays for on every turn: how well it can orient in the place, and what the tool surface costs it. Both measured for the first time this release.
Fewer tokens
The advertised tool list is re-sent on every request, so a loaded toolset is a recurring cost rather than a one-off — and loading was one-way. A session that ran a single playtest carried the runtime domain's ~13.2k tokens of schemas on every later turn whether or not it played again.
load_toolset now accepts unload:
{"unload": ["runtime"]}It may be sent on its own. Core is never released — dropping it would strand the session with no way to search for or load anything back. Both transports honour it: the stdio server deactivates and re-sends tools/list_changed, and the stateless HTTP transport shrinks the next tools/list.
Measured against the real definitions:
| tools | ~tokens | |
|---|---|---|
| Full catalog | 218 | 49.9k |
| Always-on core | 29 | 4.8k |
runtime alone |
35 | 13.2k |
tool_catalog_search and load_toolset now report approxTokens per domain, so the agent can see the price before paying it. npm run tools:token-report prints the full breakdown; --check fails when the always-on core set exceeds a 6000-token budget, and runs in release:check and CI — a tool added to the core set taxes every request of every session, including the ones that never call it.
Better orientation in the place
get_changes_since advanced its baseline as a side effect of being read. A snapshotId silently meant "since my previous call" rather than "since the baseline", so asking the same question twice reported an unchanged world — and an agent had no way to ask what it had built over a session, the one question the snapshot id looks like it answers.
The baseline now holds still. Pass rebaseline: true for the previous polling behaviour, where advancing it is the point. New response fields since (baseline / previous-call) and baselineAt say which question was answered and as of when, so a quiet world is distinguishable from a baseline that just moved.
This also fixes roblox://world/changes, where a re-fetch consumed the changes and returned an empty diff with nothing to explain why — a resource read is a read.
Fixed
- The stdio server changed the advertised tool list even when
load_toolsetfailed. It applied the transition whenever the tool returned, without checkingisError, so a partly-valid request answered "error" and expanded the list anyway, leaving the client's view of the tool surface and the server's disagreeing. The Streamable HTTP path had always guarded onisError; both stdio branches now match it.
Compatibility
get_changes_since's default answer is now baseline-relative instead of poll-relative — pass rebaseline: true for the old behaviour. load_toolset's toolsets is optional only when unload is present; existing calls are unaffected. A present-but-malformed toolsets or unload is now rejected rather than coerced to an empty array, which previously reported success for a release that never happened.
Full notes in CHANGELOG.md.
v4.2.0 — close the Luau injection holes, and stop reporting success with nothing in it
Security
Eleven caller-supplied values across seven builder tools reached generated Luau without being escaped, so a crafted argument executed arbitrary code in the Studio plugin's edit context.
This mattered more than the count suggests: none of those tools declares the studio.execute effect. That effect is what the builder profile filters on — the profile README describes it as "arbitrary Luau execution denied" — and what the execute_luau safety gate hangs off. Both were bypassed by tools that promise neither.
Affected: template_create_simulator_game (currencyName, inside a Luau string literal, so a quote closed it), template_create_tycoon_game, template_create_round_game, template_create_obby_game and environment_create_day_night_cycle_script (numeric arguments typed number but never checked at runtime, so a JSON string was emitted as code), plus apply_theme and design_lint (minTextSize). environment_create_day_night_cycle_script also placed scriptName inside the [==[ … ]==] literal holding a generated script's Source, where a name containing that delimiter ended it early.
Every value now routes through luaString/luaNumber. Regression tests drive each builder with a string that closes its quote, a name that closes the long bracket, and a "number" that was never a number — and the detector itself is tested, because a marker that vanishes along with the literals it hides in proves nothing.
Upgrade if you expose these tools to untrusted input.
Added
get_spatial_layout— where things physically are. Every other scene read answers a question about the tree; none answered the one you have to settle before placing a single part: how big the built area is, where the ground is, and which patch is empty. Measured 19.8× cheaper than the read pattern it replaces (651 vs 12,851 tokens on 121 objects; 20,000 parts in under a second).asset_fit_plan/asset_fit_apply— measures how a model sits in the scene against a Roblox character, and applies scale and pivot as one undo waypoint. Scale is absolute against the authored size, so applying twice does not compound.asset_sanitize_plan/asset_sanitize_apply— reports what the scripts inside a model you did not write actually do, then disables or removes them. Script source is never returned.
Fixed
mass_get_propertyreported success while returning no value at all for anything but a primitive. The response encoder drops keys holding userdata, soColor,Material,Size,Position,CFrameandBrickColor— most of what you batch-read while building — came back as successes with nothing in them. Values are now tagged; primitives still pass through as primitives.execute_luaucould not produce a Studio Undo waypoint. The plugin has always recorded one for any script arriving with anundoLabel, but only generated builders sent one, so the most general write path landed outside the undo stack.propose_next_actionsent agents to fix a script that does not exist: it pulls dotted names out of error text, and a URL host is a dotted name. An episode whose errors were all asset fetches came back as "openassetdelivery.roblox.comand fix it" — a self-driving loop with no exit.- Batch create and duplicate summaries reported
{succeeded: 4, failed: 0}next to properties the engine had rejected. They now carrywithPropertyErrors. - The batch mutation tools accepted a string where an array belongs.
inputSchemais advertised but never enforced server-side, and a string has a.length— somass_create_objectstold the safety gate "create 24 objects" and wrote "created 24 objects" into the operation history for a batch that never existed.
Changed
- The Luau this server generates now runs under Lune against a real DataModel, in
release:checkand in CI. Generated Luau is where the read tools compute their answers, and none of it was reachable from Jest — it could only be checked by hand against a live Studio, which in practice means checked once and then never again. release:checknow runs the 10,000-request fault-injection benchmark. It used to live only in a separate alias, so a green local check could still fail CI.
Full detail, including the reasoning behind each fix, in CHANGELOG.md.
v4.1.0 — cut the token cost of the read surface
This release cuts what BloxForge costs an AI agent to use. Everything in it was found by driving the MCP server against a live Roblox Studio session and measuring the responses.
Measured against a live place
| before | after | |
|---|---|---|
get_file_tree from the game root |
326 KB / 2345 nodes | 5 KB / 99 nodes |
capture_screenshot (default) |
918 KB, 3130×1760 | 326 KB, 1568×882 |
structuredContent duplication |
on every tool | only the 56 declaring outputSchema |
load_toolset over /mcp |
tools/list never changed |
29 → 74 |
Fixed
get_file_tree spent 96% of its response on things nobody authors. In a place holding 24 parts, Stats (1194 nodes), StylingService (560), MemStorageService (171), CoreGui and PluginGuiService (92 each) were 2246 of 2345 nodes, and another 107 services were present but empty. One call could exhaust an agent's context to describe 99 nodes of real content. Filtering happens in the plugin, so the data never crosses the bridge. It applies only to the DataModel's own children and only when the caller named no root — get_file_tree on game.CoreGui is unchanged, include_internal: true restores the full walk, and the response reports how many services it left out. A denylist rather than an allowlist of authorable services: if Roblox ships a new noisy service the cost is a larger response, never hidden user content.
load_toolset did nothing over the Streamable HTTP transport. applyToolset — the only caller of registry.activate — lives on the stdio server. Over /mcp the tool reported 74 tools loaded, the very next tools/list still returned the 29 core ones, and client_hint blamed the host's schema-refresh step for something the server had never done. The tools stayed callable blind throughout, so nothing failed loudly.
capture_screenshot sent 5.2× the pixels it needed to. A Retina Studio window captures 3130×1760 for a 1365×768 logical viewport, and that raw RGBA crossed the bridge in full. Vision models resize past roughly 1568px anyway, so the extra pixels were transferred and then discarded. The new maxWidth defaults to 1568; maxWidth: 0 returns the native capture for reading fine text. The reported size and the simulate_mouse_input coordinate conversion always describe the image actually sent, so click coordinates stay correct at any width.
Argument errors named nothing you could act on. mass_get_property answered "Paths array and property name are required", leaving an agent to work out that those are paths and propertyName — a wasted round trip per miss. 23 messages across six files now name the parameter, each checked against the tool's declared required list. The ui_create_* message named a tool that does not exist: TextLabel.toLowerCase() produced ui_create_textlabel rather than ui_create_text_label.
load_toolset and tool_catalog_search answered malformed requests instead of rejecting them. {"toolsets":"scene"} coerced to an empty selector list and returned a success shape; tool_catalog_search with no query ranked the catalog against an empty string and returned the first eight tools as if they were matches.
Error classification missed "requires". A handler writing get_roblox_docs requires a name fell through to UNKNOWN with no recovery hint instead of INVALID_ARGUMENT.
The protocol handshake now fails closed. A Studio plugin older than protocol v3 gets a 426 with an exact instruction, not the 409 the plugin retries forever.
Changed
structuredContent is attached only to tools that declare an outputSchema. It is a byte-for-byte copy of the text block, so attaching it everywhere charged each response twice — 45% of the bytes measured over a live session — and 157 of 213 tools declare no schema, leaving a client nothing to validate the copy against. The MCP specification's compatibility guidance runs the other way: a server returning structured content should also send the serialized JSON as text, and that text is what every response already carries. The text channel is unchanged for every tool, so a client reading content sees exactly what it saw before.
Added
Asset Manifest v1 — bloxforge.assets.json, plus asset_manifest_status, asset_manifest_plan and asset_manifest_scan. An asset in a place is an opaque numeric ID; nothing recorded which local file produced it, with which import settings, or which version is published. asset_manifest_plan returns an immutable planHash covering the manifest and the current content of every file it references, so swapping a texture between preview and apply invalidates the preview.
mass_delete_objects — the one bulk CRUD verb that had no bulk form. The whole batch is a single undo step, and it is wired to the safety manager's bulk_delete kind, which had been implemented but never connected to any tool.
Undoable generated Luau. execute_luau opens a ChangeHistoryService recording when the caller declares a label, so every recipe, terrain, lighting and mutation-plan write is one undo waypoint. Verified against a running Studio: one Ctrl+Z reverts the whole call.
New build gates
check-argument-errors.mjs and check-undo-coverage.mjs run in protocol:check; check-doc-links.mjs runs in docs:check; CI enforces a test-coverage ratchet. Each gate was verified to fail when the thing it guards is broken.
npm run release:check — exit 0, 961 tests.
Full changelog: v4.0.3...v4.1.0
v4.0.3
Added
project_reconcile_plan / project_reconcile_apply / project_reconcile_status — one ordered, resumable flow over the existing toolchain tools.
Every individual operation was already safe; what was missing was the order. An agent had to work out for itself that rokit_status precedes rokit_install, that a Wally install is pointless before the lock validates, and that a sourcemap generated before the packages exist describes a tree that does not.
Reconcile owns that order and nothing else — it composes the same tools behind the same plan / confirm / planHash contract rather than becoming a second, less-reviewed way to run them.
It restores declared state and never invents new state. Installing the exact version rokit.toml pins, or the packages wally.lock already resolved, is a repair. Choosing a version, resolving a new lock, editing the Rojo tree or migrating Aftman is a decision, and each comes back as a blocked step naming the [automation] flag in bloxforge.toml that would permit it — all four off by default.
- Single-writer lease at
.bloxforge/locks/project-reconcile.lock; a second agent getsanother_reconcile_is_runningrather than a half-applied project, and a lease whose process is gone is treated as stale. - Journal at
.bloxforge/reconcile/<runId>.json; the samerunIdresumes an interrupted run instead of repeating finished steps. - State is re-read after every mutation rather than precomputed once —
rokit_installchanges which tools exist, and therefore what the remaining steps should be. - Every run finishes on the full strict project verify.
Fixed
- The Inspector plugin could not load.
ClientBrokeris packaged in both variants and requiresInputHandlers,EvalRuntimeHandlersandBreakpointHandlers, which the reduced Inspector build omits, so the first require failed and the plugin never started. Those three now redirect to Inspector stubs that refuse the endpoint, keeping runtime Luau execution out of the read-only package. InspectorBreakpointHandlersexported the wrong module shape — a default object, where its callers are compiled against the real module'sexport =and index the module table directly, soBreakpointHandlers.init(plugin)would have been nil even once the module resolved.build-plugin.mjsrefuses to package a variant that requires a module it does not ship, and fails when a source rewrite matches nothing. This is the check the assertions on the finished asset cannot make: an omitted module is missing for exactly the reason it is supposed to be missing.- The pinned Lune bootstrap caches under the user's home instead of the system temp directory. A cache hit skips the checksum and
/tmpis writable by every local account, so the previous path let anyone with an account on the machine have the release gate execute their binary.BLOXFORGE_TOOL_CACHEstill overrides it. shapeListResponseno longer promises apaginationblock it does not always attach — four of its five paths return the caller's object untouched.
Verified
Full CI green on the released commit, including the real-CLI toolchain matrix on Windows and macOS.
Full changelog: https://github.com/princeofscale/bloxforge/blob/v4.0.3/CHANGELOG.md
v4.0.1
A follow-up to 4.0.0 covering the three items deferred out of the post-release review. No breaking changes.
Tool effects are declared, never inferred
Effects drive authorization — the inspector and builder profiles, and capability allowlists — but they were guessed from the tool's name:
const EXTERNAL_TOOL = /(asset|marketplace|image_generate|pollinations|wally|publish|import_rbxm|export_rbxm)/;That was wrong in both directions. It over-declared: export_rbxm asks Studio for bytes and writes them to disk, yet was marked as reaching the network while its actual local write went undeclared. And it under-declared, which is the dangerous half: a new tool that genuinely reaches the network inherits no network.external unless its name happens to match, and a capability policy would wave it through.
ToolDefinition.effects is now required, so an omission is a compile error rather than a silent guess. All 209 tools declare their own. They were seeded from the previously computed values so nothing could quietly lose an effect; exactly three changed, each verified by reading the handler:
| tool | before | after |
|---|---|---|
export_rbxm |
studio.read, network.external |
studio.read, local.files.write |
get_asset_provenance |
studio.read, network.external |
— |
import_rbxm |
studio.write, network.external |
studio.write, local.files.read, network.external |
Syncback recovery snapshot honours the project's ignore paths
The native syncback rollback snapshot now skips paths the project declares through globIgnorePaths and syncbackRules.ignorePaths, and both lists are part of the plan hash so changing them invalidates a preview. Rojo evaluates them per path relative to the project directory and refuses to write to a match, so those files cannot need restoring.
The snapshot is deliberately not narrowed to the dry run's reported paths: rojo syncback --list is human-readable output rather than a machine contract, and any path a parse missed would be unrecoverable after a partial failure. The 5000-file / 100 MiB ceiling stays, and its message now names both ignore lists as the way out.
ESLint 10
eslint and @eslint/js move to 10 together; typescript-eslint 8.65 already declares ESLint 10 support. Two rules new to recommended were fixed rather than switched off:
preserve-caught-error— 17 rethrows discarded the original failure when wrapping it. They now carry{ cause }, so anerrnoor a timeout stays readable underneath the BloxForge-level message.no-useless-assignment— 8 initializers whose value no code path could observe.
Full detail in CHANGELOG.md.
v4.0.0
BloxForge 4.0.0 makes Rojo, Rokit and Wally first-class: the MCP server drives the real CLIs through explicit read/plan/apply tools instead of imitating their semantics.
Breaking
- Node.js 20+ is required. The published 3.0.0 supported Node.js 18.
sync_pull,sync_statusandsync_pushare deprecated in favour ofrojo_syncback_plan/rojo_syncback_apply.rojo_syncback_applynow requires theplanHashreturned by the preview. A stale plan is refused rather than applied.
Added
rokit_*andwally_*tools backed by a real TOML reader: toolchain detection, manifest reads, shim-vs-manifest-vs-running version status, confirmed install/add/update, plus Wally manifest, lockfile, dependency graph, lock validation, search, locked install, update, and a check that installed package directories are actually mounted by the Rojo project.- Rojo 7.7 file coverage:
.luau,.server.luau,.client.luau,.plugin.lua,.plugin.luau,.project.jsonc,.meta.jsonc,.model.jsonc,.jsonc,.yml,.yaml— matching Rojo's own sync rules, which also fixes what the syncback rollback snapshot covers. includeNonScriptsonrojo_generate_sourcemap. Rojo emits only Script/LocalScript/ModuleScript by default, so folders and models could not be resolved through a generated sourcemap.instancePathSegmentson instance/source resolution, so an Instance whose name contains a dot is no longer ambiguous.resetBaseline, which quarantines an unusable.bloxforge/rojo-state.jsonand rebuilds the sync baseline explicitly.- A Rokit + Wally CI job that installs a checksum-pinned Rokit and asserts the installed Wally's actual
--lockedbehaviour.
Fixed — data integrity
- A Rokit- or Aftman-pinned project no longer silently runs an unrelated global Rojo. Resolution probed
PATHbefore honouring the manifest, so a project pinned to 7.7.0 with no installed shim ran whatever version happened to be installed. - Sourcemap resolution no longer prefixes every path with the project name. Rojo names the sourcemap root after the project, not
game, sogame.ReplicatedStorage.Sharednever matched. - Only a leading
gamesegment is treated as the DataModel. An Instance legitimately namedgamewas dropped from the middle of a path. sync_pullre-reads a rename source before moving it, andsync_pushre-reads each file before sending it. Both previously wrote a baseline describing content that was never reviewed, so the next plan reported nothing to reconcile and the edit was lost.- The sync state file fails closed and is written inside the same transaction as the files it describes. A corrupt or foreign state file used to read as "never synced".
- Studio content identity moved from a single 31-bit rolling hash to two independent accumulators plus byte length, so a collision no longer makes a changed script look unchanged.
- Native syncback rollback covers every regular file, not only files the classifier recognised, and a syncback whose dry run failed is refused.
- Studio Instance names that no portable file name can represent are reported as conflicts instead of encoded into a name Rojo would decode differently.
set_script_sourceaccepts an empty string, and its destroy-and-recreate fallback — which preserved onlyNameandEnabled— is gone.
Fixed — toolchain and process
rojo servereadiness is a TCP connect to its port rather than a match against its stdout banner, which is not API and has changed between releases.- Removed the
rokit run rojo --fallback. Neither Rokit nor Aftman has arunsubcommand; both work through installed shims. wally install --lockedis probed rather than assumed. The flag is absent from the released 0.3.2, and silently dropping it would rewrite the lockfile it exists to protect.- The toolchain-manifest search is bounded by
BLOXFORGE_PROJECT_ROOT, and the resolution cache keys on the shim so an externalrokit installtakes effect without a restart. - Rokit version pins compare component-wise; Wally path comparison is case-sensitive on Linux.
Security
- Unauthenticated non-loopback bridge bindings are refused; internal control requests are authenticated.
- Every QualityTools and Rojo path is confined to the canonical project root, including symlink and traversal escapes.
- Studio plugin installation is atomic and validates release type, variant and checksum.
- Inspector read-only and builder no-arbitrary-Luau profile policies are enforced at the registry.
The full list, including everything inherited from the 3.x line, is in CHANGELOG.md.
v3.0.0
v3.0.0-rc.1
What's Changed
- [ImgBot] Optimize images by @imgbot[bot] in #26
- feat: BloxForge v3.0.0-rc.1 release candidate hardening by @princeofscale in #27
Full Changelog: 2.20.2...v3.0.0-rc.1