Releases: theisegoria/game-asset-mcp
Release list
v0.4.0
No defect fixes. Two additions, one of them the first proof of something that
had only ever been asserted.
generate_sound_effect is now proven built, end to end
It has always registered, and its provider layer was thoroughly tested —
request contract, credit safety, undocumented response shapes, URL recognition,
retrieval discovery. The tool was tested by nothing, so "it registers" was
the only claim anyone could honestly make about it.
It is now driven end to end with the provider stubbed and everything else real:
the spend ledger, the job store, the bounded poll, the HTTPS download, the
atomic write, the workspace layout, asset.json, and the JSON a client
receives. Five properties, each falsified against a reverted fix:
- clips land on disk carrying the bytes the server sent, not just a path in
a response asset.jsonrecords prompt, provider, model and generation id — a clip
nobody can trace back to a prompt is what this server exists to prevent- a
PENDINGpoll is retried rather than abandoned - a provider failure becomes a failed job, not a thrown call: a spent credit
with a traceable record beats an exception with none - the spend ceiling is charged before the provider is contacted, and moving
that charge after the call is caught by this test and nothing else
⚠ Still not a substitute for one live call. Leonardo documents the sound-effect
request contract but not its response shape, so the parsing in
providers/audio/leonardo.ts remains unverified against the real API and is
marked as such below. What is proven is that everything around it is wired up.
The test harness now accepts context overrides, so any provider-backed tool can
be exercised this way without a credential.
A Features section in the README
A capability inventory rather than a tool list: what the pipeline does, what the
offline half answers, the glTF correctness the tools enforce on your behalf, the
safety properties (and that most of them exist because they were once wrong),
spend control, provenance, and how it fails. Every number in it was checked
against the running server rather than recalled.
v0.3.9
A fifteenth review, six findings. Four are defects that 0.3.8's own fixes
introduced, and one is in the check written to prevent exactly that.
A non-strict reader turned a refusal into a stated falsehood
Making extract_pbr_trio read non-strictly was right — one broken normal map
should not cost you the albedo and roughness you could still have had. But it
converted a loud, correct refusal into a success that asserts something
untrue:
| result | |
|---|---|
| 0.3.8 (strict) | isError: true — ENOENT … THE_NORMAL_MAP_IS_MISSING.png |
| 0.3.8 (non-strict) | success: "normal was not written at all: the material declares no such texture" |
The material does declare one. The reader knew precisely what was wrong and
said so, into a logger constructed inline and never read. There are now three
states — loaded, declared but unreadable, not declared — and the reader's
diagnostic is surfaced so the caller learns which file is missing.
The guard against false greens had two false greens
assert-blender-tests-ran.mjs, added last release to stop CI passing by
skipping, could itself pass while proving nothing: its suite list was
hardcoded, so renaming one describe made that suite invisible and its tests
were reported as allowed platform skips; and "at least one must have passed"
was documented but never implemented — the count was used only in a log line.
The list is now derived from the source, and a suite present in the source but
absent from the report is a failure.
Half-applied, again
anythingDrawnhad a third site.base_color_texturereported "no base
colour texture" at severityerrorabout a material that binds one.bounds.emptyreached no consumer — the fifth instance of that class
here.bounding_box_finitepassed while reporting0.000 x 0.000 x 0.000 mfor a file where no finite vertex was found.- An orphan texture is not an extension-bound one. 0.3.8 counted both,
reopening thetexture_resolutionandpower_of_two_textureswarnings that
scoping textures to drawn materials exists to suppress — and on one file made
inspect_assetreport only the texture nothing samples. The reader's log
now decides it, and both directions are pinned. - Reader diagnostics were misattributed. Drained inside the texture loop,
so the first texture with a missing image took every message: with two
missing sidecars, one texture was labelled with another's URI and the other
named with none. Reported once, unattributed, which is what they are.
The sentinel refused where refusing protects nothing
A mesh whose faces are all zero-area has no non-zero edge, so the safety
check found nothing to protect and skipped: 6 → 6 triangles while the receipt
called the object cleaned. "No real geometry" is safe, not unknown.
"scale-independent by construction" is retracted — the third overreaching
claim about this function. Two files with identical world geometry can still be
answered differently when one is authored so finely that no expressible
threshold is safe for it; that asymmetry is forced, and the honest response is
to skip and say so. The 10× factor's justification is also corrected: the
dissolve removes faces by altitude, not shortest edge, so the factor is a
sound proxy rather than the proof the comment claimed.
Verification
The node-scale sweep added last release could not see its own rule — its
fixture holds vertices at 0…1 whatever the node scale, so all five cases ran
the identical branch. It swept the axis the rule ignores. There is now a sweep
over local edge length, across the threshold where the answer must change.
Three tests written this release passed for the wrong reason before they were
real: one insertion silently matched nothing, one passed a policy flag nested
where the schema is flat, and one asserted a field that does not exist. Each
was caught by reverting the fix and watching the test stay green.
v0.3.8
A fourteenth review, six findings. The first is the same mistake for the third
consecutive release, and it is worth stating why it kept surviving.
Framing a LOCAL question in WORLD terms — three times
mergeDistance: 0 means "merge nothing, but still repair faces that are
degenerate at any scale". Two releases tried to express that as a world-space
number and both were wrong, in opposite directions:
| release | framing | failure |
|---|---|---|
| 0.3.7 | 1e-6 local |
world radius grew with scale; a mesh with 4e-7 local features lost 98% of itself and reported ready |
| 0.3.7 fix | 1e-6 world / divisor |
gate reduces to divisor <= 1, so every object scaled above 1.0 silently stopped being repaired |
Each time the fixture sat at scale [1,1,1] — the one value at which that
release's bug is invisible. The second cliff sat at 1.0001.
The dissolve runs on LOCAL coordinates, so the safety question is the mesh's own
local feature size and nothing else. It now dissolves at Blender's floor exactly
when the mesh's smallest real local edge is at least 10x that floor:
scale-independent by construction, which is what both previous framings lacked.
Measured across node scale 0.001 / 1 / 1.0001 / 2 / 1000: repaired at every one.
The test sweeps scale, not merge distance, and kills both historical
framings — the strongest check available.
"monotonic by construction" is retracted; mergeDistance: 0 is a sentinel,
not a distance, and is documented as one. 0.3.7's entry is corrected in place
rather than quietly edited.
Reports that stated things that were not true
- The bounding box still used the OLD scene predicate, so one response
carriedsceneGraphFallback: falsebeside the warning "no scene references
the meshes" — contradicting itself, withmin_dimension(severityerror)
judged against geometry the same report called undrawn. hasUVsisprimitiveCount > 0 && missingUv === 0, so a file that draws
nothing produced "at least one primitive has no TEXCOORD_0" about a file
whose every primitive is fully unwrapped. Three errors for one cause, two of
them untrue. The attribute checks now run only when something is drawn.
Textures
- One missing sidecar discarded every other texture in the file.
extractTexturesread strictly whileinspectGltfdid not, and that reader's
comment already stated the rule. All three readers now agree. - A texture bound through an extension vanished. Scoping to drawn materials
was done by enumerating the five core PBR slots, which cannot see a
KHR_materials_clearcoat / sheen / transmission binding. Now three cases are
distinguished: bound-and-drawn (counted), bound-but-undrawn (excluded),
binding not parseable (counted, because "unknown" is not "unused"). - The missing-image branch added in 0.3.7 was dead code under strict
reading, which is why reverting it left the suite green — and why that
release's "every fix observed to fail" claim was false for it. Now reachable
and pinned.
v0.3.7
A thirteenth review, six findings. The worst is mine from 0.3.6, and it is the
same defect that release's own comment quotes while introducing it.
Data destruction
-
mergeDistance: 0destroyed geometry on a scaled object and reported
success. 0.3.6 special-cased the zero branch as a1e-6constant in LOCAL
units with no divisor, while both other branches divided. The world radius was
therefore1e-6 x divisor, and the branch below skips whenever the request
is narrower than that — so asking for zero merging applied a strictly wider
repair than asking for a small positive one. Two files with byte-identical
world geometry went to 100 and 0 triangles; a partial case lost 98% (102
triangles to 2) withreadyToTexture: trueand the skip counter reading zero.The test could not see it: hardcoded to one merge distance, against a fixture
at scale[1,1,1]— divisor 1 is the single value at which it is invisible.
Fixed by deleting the special case rather than patching it, so every path runs
the one divisor rule and is monotonic by construction.
Wrong verdicts, at severity error
sceneGraphFallbackasked "does the default scene draw nothing?" rather
than "does any scene reference a mesh?". A Blender export whose default scene
holds only a camera, geometry in a second scene, is ordinary — and it took the
mesh-library fallback, counting undrawn meshes as drawn. That restored both
defects the narrowing exists to prevent. It worked only when the default scene
happened to be non-empty, which is exactly what its test covered.- Materials, textures and the PBR summary stayed file-scoped while geometry
was narrowed, so the two halves of one report described two different files.
A normal map bound only by an undrawn collision proxy failed
tangents_for_normal_mapon a model that correctly needs no tangents; a
base-colour texture on an undrawn LOD satisfiedbase_color_texturefor a
drawn mesh that has none. Textures now derive from the drawn materials.
Values that reached no caller
- The batch dropped the new dissolve-skip flag — the fourth consecutive
release in which that seam lost a flag the layer below computed. textureFailureson the unreadable-container path was pinned by nothing;
deleting it left the whole suite green. Now covered end-to-end through a real
HTTPS server, because the download layer refuses non-HTTPS URLs outright.- The four inspection fields were pinned only at the helper.
ok()takes
unknown, so the compiler guards summarizer-to-interface and nothing guards
interface-to-client — the exact hop where four fields have now been lost. A
mutant destructuring them out on the way to the response compiles cleanly;
only a test through a real MCP client can see it. - A texture with no image data was silently skipped, vanishing from
textureCountwith nothing said.
Verification
Every fix reverted and observed to fail, each mutant confirmed to compile and
to have actually applied. One near-miss worth recording: a field assignment
landed while its interface declaration silently did not, and the tests passed
while tsc failed, because vitest does not typecheck. The rule that keeps
catching this remains: put the field on the output type first.
v0.3.6
A twelfth review. Two of these are mine from 0.3.5 — one a regression, one the
same defect class for the third consecutive release.
I claimed to ship two fields that reached no caller
undrawnMeshCount and undrawnTriangleCount were declared, computed,
documented in the source as "Reported, not discarded", and headlined in 0.3.5's
release note. They were in no response. They were never added to the output
interface, so tsc had nothing to object to — a field that exists nowhere
cannot be a type error. The test meant to pin them is titled "and reports the
rest separately" and asserts neither.
This is the third consecutive instance of one class: weldSkipped (0.3.4),
factorApplied (0.3.5), these (0.3.5). The rule that actually prevents it:
put the field on the output type first. The moment they were added there,
the compiler named both missing sites immediately.
A regression 0.3.5 introduced
Narrowing every count to the drawn scene turned a mesh library — meshes
present with no scene graph referencing them, valid glTF that real exporters
emit — into 0 triangles, which validate_game_asset refuses at severity
error. The report contradicted itself: "nothing renders as a solid surface"
beside a real bounding box, because computeBoundingBox carries a documented
fallback for exactly that shape and the triangle counter had none. Two readers
of one fact disagreeing, which is the same shape as the defect 0.3.5 fixed.
Half-applied fixes, again
- The drawn-scene narrowing reached
triangleCountand the bounding box, and
not the attribute counters.hasUVsderives frommissingUv, and
uvs_presentis severityerror— so a never-drawn, unwrapped collision
proxy in a second scene FAILED a model whose drawn mesh is perfectly
unwrapped. The exact mirror of the bug 0.3.5 fixed, in the same function. - The reservation cleanup wrapped only the plane loop and stopped one
statement short of the receipt write. Both of its tests fail inside the
loop, so neither could see it — and the leak was worse than the one it fixed:
four fully written planes holding the canonical names after a call the
caller was told had failed. mergeDistance: 0meant "repair nothing". The weld gate and the dissolve
gate were one flag, folding together "the caller asked for zero welding" and
"the threshold cannot be expressed". Only the second is a reason to skip the
dissolve: a zero-area face is degenerate at any threshold, including
Blender's 1e-6 floor. Measured on a quad plus five zero-area triangles:
7 to 2 at the default, 7 to 7 at zero, both reportingobjectsCleaned: 1.
The caller most likely to pass 0 is the one protecting screws, gems and PCB
detail — exactly who most needs the repair.- An unreadable container reported
textureCount: 0with no failure named,
indistinguishable from "no embedded textures". Draco/meshopt/KTX2 GLBs land
there and providers do return them. The per-texture half was fixed in 0.3.5;
this sibling path was not.
Verification
0.3.5's verification claim was independently re-checked this round and found
accurate — all ten of its fixes were confirmed pinned, each mutant verified
to compile and to have actually applied. That is the first release note here
whose verification claim survived review.
v0.3.5
An eleventh review found nine defects. One destroys geometry; one is a false
claim I published in 0.3.4's own release note.
The correction first
0.3.4 said every fix in it was pinned by a test observed to fail against
reverted code. Five of its six fixes were pinned by nothing — reverting each
left the suite fully green. The discipline was applied to 0.3.3's fixes and then
written up as though it had been applied to 0.3.4's. All six are pinned now, and
0.3.4's claim is left standing with a correction beside it rather than quietly
edited.
Data destruction
- The unrepresentable-threshold fix reached
remove_doublesand not
dissolve_degenerate, thirteen lines below it in the same function. Not an
edge case but an identity: the weld is skipped exactly when the local
threshold falls under Blender's 1e-6 floor, and the dissolve's old clamp then
produced exactly 1e-6 — by construction wider than the caller asked for. So
the honesty counter fired precisely on the runs where the protection had
failed. Two meshes with byte-identical world geometry, differing only in how
scale was split between node and vertices, normalized to 100 and 0
triangles, and the husk was reportedreadyToTexture.
Wrong output
baseColorFactorwas ignored whenever a base-colour texture was present.
0.3.4 claimed "both branches now apply the factor"; that meant both branches
of metallicRoughness. A red-tinted white texture exported pure white. Albedo
is now multiplied in linear light, unlike the data channels.occlusionStrengthandnormalScalewere dropped entirely. Occlusion is
1 + strength x (sampled - 1), not a multiply — at strength 0 a multiply
gives black, the exact opposite of "no occlusion".- The bounding box unioned every scene while the triangle counter walked
one, with doc comments that contradicted each other.sizeMetersfeeds
min_dimension, severityerror: a flat plate correctly refused on its own
was reported shippable once an undrawn second scene existed. - Meshes in a non-default scene were counted as drawn, so a three-LOD file
reported 3x what a renderer submits. Drawn and present are now separate
counts (undrawnMeshCount,undrawnTriangleCount).
False success
factorAppliedwas declared, passed by two call sites, headlined in a
release note, and never written into the receipt. Optional-property typing
meanstscsays nothing. This is theweldSkippeddefect verbatim, one
release later, inside the fix that promised to report it.stdoutTruncatedreached no tool response at all. It matters here
specifically: the receipt is the last line of stdout, so a dropped tail is how
a forged receipt near byte 0 wins.- A partial texture extraction reported
textureCount: 0. Thecatch
wrapped the whole loop, so a failure on texture 3 of 5 discarded the record of
1 and 2 — already on disk — andasset.json, the provenance document, denied
them. The guard is now per-texture, and failures are named in the response. extract_pbr_triohad no cleanup for its reservations, the third call
site of a fixnormalize_meshand the batch both have. A failure partway
left earlier planes plus a zero-byte<stem>_metallic.pngholding the
canonical name — sorting first in any glob — while the caller was told the
call had failed. Cleanup is keyed on device+inode, so it can only remove a
file this call still owns.- The summary called a missing normal plane a "flat constant". The normal
plane has no factor fallback and is simply not written; absent and constant
are different answers. Reported separately asmissingPlanes.
Verification
Every fix above was reverted and observed to fail, and each mutant was
confirmed to compile and to have actually applied — a build failure is not a
kill, and a mutation that silently matched nothing is worse.
New: tests/helpers/tool-harness.ts. Until now nothing in the suite invoked a
tool through its registered handler; it tested domain helpers and the Blender
subprocess and stopped at the tool boundary. That is the structural reason
"computed correctly, dropped on the way out" kept shipping — the helper is
right, so a helper test cannot see it. Tool responses are now asserted as the
JSON a client actually receives.
One test in this release passed for the wrong reason before being fixed, and it
is worth naming: the destination: "" tests asserted only that the call failed,
against a nonexistent model, a nonexistent job and an unconfigured provider —
all of which fail anyway. Reverting the constraint left them green.
v0.3.4
A tenth review. Six of these are fixes that 0.3.3 made somewhere and left
undone somewhere else — the same defect, a second time, one call site over.
- The receipt forgery fix did not survive a chatty Blender. 0.3.3 took the
LAST matching stdout line, but stdout capture stopped at 4 MiB — so on a real
export with megabytes of chatter the genuine receipt was dropped by the cap
and a forged line near byte 0 became "the last one". Receipt scanning is now
independent of the capture cap, andstdoutTruncatedis reported. extract_pbr_triodropped the factor whenever a texture was present. The
glTF effective value is texture x factor. 0.3.3 fixed this for the untextured
branch only, so a material declaringmetallicFactor: 0alongside a shared
metallicRoughness texture still exported fully metallic. Both branches now
apply the factor and report it asfactorApplied.triangleCountsummed every scene. glTFscenesare alternatives and a
renderer draws one. A mesh referenced from two scenes was counted twice, so a
12-triangle asset reported 24 and failed a 20-triangle budget — while Blender,
which walks one scene, disagreed systematically. Only the default scene counts.- The batch dropped the honesty flag 0.3.3 had just added. It copied three
receipt fields and notweldSkipped, so a mesh whose weld was skipped came
back "game-ready" with no sign a repair had not run. - The batch discarded the only text naming a failure. A failed item kept the
error message and threw awaystderrTail, which holds the Blender traceback.
Now surfaced aserrorDetail. destination: ""was accepted by three tools, resolving to the process
working directory — which an MCP client chooses, not the user. Nowmin(1).
Verification
⚠️ CORRECTION, added in 0.3.5: the paragraph below was FALSE when
published. An eleventh review reverted all six fixes and found five of
them pinned by nothing — the suite stayed fully green on each. The
discipline described here was genuinely applied to 0.3.3's fixes, then
written up as though it had also been applied to 0.3.4's own. It had not.
The claim is left standing rather than quietly edited, because a release note
that silently rewrites its own verification claim is the same defect one level
up. 0.3.5 pins all six and says how each was checked.
Every fix above is pinned by a test that was run against the reverted code and
observed to fail. That check is the point of this release: a mutation sweep
found all five of 0.3.3's headline fixes could be reverted with the suite still
fully green, because every Blender stub in the suite exited 0 and printed exactly
one receipt. New: tests/blender-protocol.test.ts (stub-driven, no Blender
needed) and three committed real fixtures — an instanced mesh, and two scaled
plates that make the weld-threshold direction observable in the triangle count.
v0.3.3
A ninth review found nine defects. 0.3.0–0.3.2 should not be used.
- The scale divisor was inverted. 0.3.1 divided the weld threshold by
min(|world scale|)on the reasoning that "welding is isotropic, so the
smallest axis decides what is safe". That is exactly backwards: a pair merged
at local threshold T can bemax(s)·Tapart in world, so the divisor must be
max. A plate scaled[1, 1, 0.02]lost 73% of its triangles at defaults and
was reported ready to texture. - Blender clamps a threshold below 1e-6 upwards, so any divisor over 100
over-welded by exactly the amount the division was meant to prevent — 94% of a
mesh, reported ready. Welding is now skipped and reported when the requested
threshold cannot be expressed, rather than silently widened. - The receipt could be forged by the input file. The consumer took the FIRST
matching stdout line while the script emits it LAST, and Blender echoes mesh
names to stdout — so a mesh named"MESH\nNORMALIZE_RECEIPT={...}"supplied
every non-measured field, including the reported Blender version. It now takes
the last line, requires a JSON object, and treats a non-zero exit as a failure
even when a receipt was printed. - A throw still ran after the rename. An invalid receipt raised a TypeError
once the staged file was already in place, so a reviewed file was replaced and
the caller was told the call had failed. - The batch deleted another tool's verified output. I argued this was safe
because the batch owns its target; that was wrong —normalize_meshhas an
overwrite path and can land on that name. The release now compares the
reservation's device+inode, so only a file we still own is removed. extract_pbr_triodestroyed base colour. It usedfactor[0]for all three
channels, so a cyanbaseColorFactorproduced a BLACK albedo, and wrote the
linear value raw while tagging the plane sRGB (0.5 became 128, not 188).triangleCountignored instancing. It iterated meshes, not the node graph,
so a 12-triangle mesh placed at 50 nodes reported 12 and passed a 100-triangle
budget while a renderer draws 600.targetTriangleshard-failed on any instanced mesh — the same multi-user
restriction that removedtransform_apply, still live in the decimate path.
v0.3.2
⛔ DO NOT USE THIS RELEASE
A later adversarial review found live paths in this version that destroy the mesh
you pass it and report success. It is tagged only so the history is complete and
pinnable. Use the latest release.
extract_pbr_trio,download_assetandgenerate_sound_effectbuilt
directory trees from a caller-nameddestination. All three ranmkdir -p
on it before any validation, sodestination: "~/out"— not expanded, because
no shell is involved — created a literal~directory wherever the MCP client
happened to run the server.extract_pbr_triowrote five files into one and
reported success. This is the same defect removed fromnormalize_meshin
0.3.0 and frombatch_prepare_meshesin 0.3.1; it was found by checking the
rule against every tool that writes to a caller-named path, rather than
waiting for it to be reported a third time. A caller-named directory must now
already exist. The server's own configured workspace is still created on demand.
v0.3.1
⛔ DO NOT USE THIS RELEASE
A later adversarial review found live paths in this version that destroy the mesh
you pass it and report success. It is tagged only so the history is complete and
pinnable. Use the latest release.
0.3.0 should not be used. A review of that release found four live paths
that still destroyed data, three of them introduced by 0.3.0's own fixes.
dissolve_degenerateran with Blender's hardcoded 1e-4 threshold, in local
space, entirely independent ofmergeDistance. A mesh of 2 mm parts at true
scale went from 3,042 triangles to zero even withmergeDistance: 0— and
the refusal named the one setting that could not fix it. Screws, gems, coins
and PCB detail all sit inside that default. The threshold is now explicit.- The zero-geometry refusal fired after the rename. The destination was
atomically replaced by a husk and the caller was told "The destination is
unchanged" — a false reassurance, which is worse than no check, because nobody
re-checks. Measured: 70,492 bytes to 224. The check now runs before the rename. - Scale is no longer baked into geometry. 0.3.0 applied
transform_apply,
which read the object's own scale and so missed a scale on a parent node —
92% of a mesh was still destroyed at defaults — and which raised "Cannot apply
to a multi user" on instanced meshes, turning a working input into a hard
failure. The world scale is now read and the threshold divided by it: parents
are included, instancing is untouched, and no geometry is mutated. - The threshold adjustment applies whether or not
cleanGeometryis on. It
sat inside that branch, so turning welding off silently degraded UV texel
density by 2.8x on a non-uniformly scaled mesh. - The receipt's scale counters were provably false for a scale like
[-1, 1, 1].
They now describe the threshold adjustment, which is what actually happens.