diff --git a/.github/workflows/site-docs.yml b/.github/workflows/site-docs.yml index c7383d4bae..690ea692ed 100644 --- a/.github/workflows/site-docs.yml +++ b/.github/workflows/site-docs.yml @@ -1,18 +1,32 @@ name: site-docs # threenative.com renders these files at build time, so a change to any of them redeploys the site: -# the guides, the agent-docs recipes, the package READMEs, the capability manifest behind the API -# reference, and the recorded MCP tool surfaces. +# the guides, the agent-docs recipes, the package READMEs, and the recorded MCP tool surfaces. The API +# reference behind `/docs/api` is the engine's generated capability manifest, and the site regenerates +# it from this repository's source on every build, so the paths below are the manifest generator's +# real inputs: each public package's export map and source, the template closure, the generator's own +# sources, and the manifest itself. `scripts/__tests__/capability-site-docs.spec.ts` derives the list +# from `scripts/build-capability-manifest.ts`, so a new import fails here instead of shipping quietly. on: push: branches: [develop] paths: - "docs/guides/**" + - "docs/verification/realism-effects-*.md" - "packages/create-threenative/agent-docs/references/**" - "packages/create-threenative/*.json" - - "packages/create-threenative/templates/starter/.mcp.json" + - "packages/core/capabilities.json" + - "packages/create-threenative/templates/**" - "packages/*/package.json" - "packages/*/README.md" + - "packages/*/src/**" + - "packages/engine-mcp/src/**" + - "scripts/build-capability-manifest.ts" + - "scripts/check-capability-docs.ts" + - "scripts/not-owned-capabilities.ts" + - "scripts/realism-effects-coverage.ts" + - "scripts/render-chain-capabilities.ts" + - "scripts/workspace-packages.ts" jobs: redeploy: diff --git a/docs/guides/README.md b/docs/guides/README.md index 00106dd1e8..5085360f3e 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -17,7 +17,9 @@ so a change here is a change to the published docs. - [Input](input.md) - [Physics](physics.md) - [Assets](assets.md) +- [Unreal assets](unreal-assets.md) - [Animation](animation.md) +- [MetaHuman heads](metahuman.md) - [UI and state](ui-state.md) - [Audio](audio.md) - [World streaming](world-streaming.md) diff --git a/docs/guides/api.md b/docs/guides/api.md index b1721253c8..ef13feca84 100644 --- a/docs/guides/api.md +++ b/docs/guides/api.md @@ -71,6 +71,42 @@ All of these export from `@threenative/core`. Physics nodes (`RigidBody3D`, `Area3D`, `CharacterBody3D`, `CollisionShape3D`) export from `@threenative/physics`. See [Physics](physics.md). +## Search the engine before you write + +An agent works from this repository's MCP servers, which are wired into every generated project's +`.mcp.json`. `threenative-engine` is the one that answers "does the engine already do this?". + +| Tool | Takes | Answers | +| --- | --- | --- | +| `engine_search_capabilities` | `situation` (required), `scope` (`"request"` or `"mechanic"`, default `"mechanic"`) | Up to 15 deduplicated capabilities for a complete cross-system request, up to 5 for one mechanic | +| `engine_capability_detail` | `symbol` (required), `importPath` when two packages export that symbol | One capability's import, signature, example, constraints and overrides | + +Search the mechanically explicit request with `scope: "request"`, then search each implied mechanic +with `scope: "mechanic"`. A genre label alone is not a query. Every result carries the +`matchedSituation` it matched and a `score`, so a weak match is visible rather than silent, and +`engine_capability_detail` is authoritative on platform support: it never states a limitation it does +not actually have. + +A reply with `verdict: "none"` is an answer, not a failed search. Follow its `guidance` and write the +behavior in your own `src/`. Only results at or above the relevance floor of `0.27` are returned. + +The server fails closed on the JSON-RPC layer, so a broken call is legible: + +| Code | Meaning | +| --- | --- | +| `-32700` | The line was not valid JSON | +| `-32601` | Unknown method | +| `-32000` | A tool rejected its arguments, with the message as written | + +The blender server answers the same way, and never fails on a machine without Blender: `blender_status` +returns `available: false` with a `cause` (`blender-missing`, `blender-too-old`, `blender-unreadable`) +and an install command per platform. Every other blender tool returns one structured result with the +same guidance, so a missing Blender reads as a fixable instruction rather than a dead server. + +The asset and sculpt servers are separate servers with their own tools and their own argument +shapes. The recipes cover them: [Finding assets](../../packages/create-threenative/agent-docs/references/finding-assets.md) +and [Sculpt from a reference](../../packages/create-threenative/agent-docs/references/sculpt-from-a-reference.md). + ## Read an API in the source Start at a package's `src/index.ts` and follow the export to its file. The doc comment above each diff --git a/docs/guides/metahuman.md b/docs/guides/metahuman.md new file mode 100644 index 0000000000..71038fb204 --- /dev/null +++ b/docs/guides/metahuman.md @@ -0,0 +1,182 @@ +# MetaHuman heads + +Put a MetaHuman head in a game and drive its expression from code, with no Unreal install, no baked +clip and no CDN fetch. One prepared specimen gives you a plain Three.js object graph, a list of +named controls, and one `update()` call per frame. + +This is the task walkthrough. [`@threenative/metahuman`](../../packages/metahuman) holds the full API +reference, including the binding-sidecar schema. + +## What you need on disk + +Three files, all served through your game's own asset loader: + +| File | What it is | +| --- | --- | +| `head.dna` | The specimen's original head DNA, exported from the MetaHuman creator or your Unreal project. | +| `specimen.glb` | The prepared head: the DNA's meshes and joint names, in glTF. | +| `bindings.json` | The sidecar that maps the rig's controls and channels onto that GLB. | + +The WASM binary is not one of them. `riglogic.mjs` and `riglogic.wasm` ship inside the package and +are checksum-verified before the evaluator instantiates them, so there is no build step for a game +to run and no file to host. See +[Building the WASM](../../packages/metahuman/README.md#building-the-wasm) only if you are changing +the rig itself. + +## Prepare the specimen once + +`bindings.json` is what makes a prepared GLB honest about the rig it claims to drive. Nothing infers +it at load time; a specimen without a sidecar cannot load. + +Validate the sidecar against the real rig and the real GLB before you build anything on top of it: + +```ts +import { + validateMetaHumanAssets, + type IMetaHumanGltfFacts, + type IMetaHumanRigFacts, +} from "@threenative/metahuman"; + +function checkSpecimen( + bindings: unknown, + dnaSha256: string, + glbSha256: string, + rig: IMetaHumanRigFacts, + gltf: IMetaHumanGltfFacts, +) { + return validateMetaHumanAssets({ bindings, dnaSha256, glbSha256, rig, gltf }); +} +``` + +It reads the rig's real joint names and the GLB's real node, mesh and target counts, then returns +the bindings only when every name, index, domain and hash holds up. A sidecar naming a joint the rig +does not have is rejected rather than ignored. + +Run this in your asset pipeline, not at startup: it needs the rig and the GLB parsed, and its result +is what you commit. `glbSha256` is optional, because it is a preparation-time contract and a loaded +model is not re-downloaded to be hashed. + +## Load a head + +```ts +import { loadMetaHuman } from "@threenative/metahuman"; + +const human = await loadMetaHuman({ + assets: ctx.assets, + model: "metahuman/specimen.glb", + dna: "metahuman/head.dna", + bindings: "metahuman/bindings.json", + lod: 0, +}); +ctx.scene.add(human.root); +``` + +`assets` is the game's own loader, which needs two methods: `model(path)` returns the loaded object +and `resolve(path)` returns the urls worth trying for a logical path. `loadMetaHuman` never opens a +file itself, and it never fetches from a CDN. + +The model is cloned per instance, so two characters never write each other's face, and `dispose()` +frees the rig without touching the geometry or textures the loader still owns. + +## Drive the face + +`human.controls` is the declared control list. Each entry carries its `alias`, the rig's `gui` +channel name, its `min`/`max` domain and its `default`. + +```ts +for (const control of human.controls) { + console.log(control.alias, control.min, control.max, control.default); +} + +human.setControls({ jawOpen: 0.4, eyeBlinkLeft: 1 }); +human.reset(); // every control back to its declared default +``` + +Values are applied on the next `update()`, and nothing is clamped. An alias the specimen does not +declare throws `TN_MH_UNKNOWN_CONTROL`; a value outside the declared domain throws +`TN_MH_BAD_DOMAIN`; a non-finite one throws `TN_MH_NON_FINITE`. A silently clamped face is a worse +bug report than a throw. + +## Update it in the loop + +Call `update()` once per frame, after any base or body animation and before the renderer draws. The +rig writes the face joints, its output is applied, and the skeleton updates as it always does. + +```ts +// In the scene: keep this removal function for the scene's teardown. +const removeFace = ctx.beforeRender(() => { + human.update(); +}); +``` + +There is deliberately no `dt`. Facial evaluation has no time step, and a body mixer's update +position belongs to the caller. + +To switch LOD, call `human.setLod(n)`. The evaluator's LOD, the visible mesh set, the active +mappings and the current expression change together, and the replacement is evaluated before it is +shown, so a switch never shows a neutral frame. A LOD the specimen does not declare throws +`TN_MH_BAD_LOD`. + +Animated maps are a pull, not a write: `human.animatedMapNames()` and `human.animatedMaps()` return +the rig's last evaluation. Bind them to your own material inputs. The handle drives joints and +morphs and never writes a material. + +## Read the diagnostics + +```ts +const info = human.diagnostics(); +// info.backend, info.openRigLogic, info.lod, info.joints, info.blendShapes, +// info.animatedMaps, info.controls +``` + +`backend` reads `"wasm"` in a browser game. In a native host that installed the MetaHuman resident, +the C++ evaluator runs instead and `backend` reads `"native"`; the game code does not change. +`openRigLogic` is the pinned upstream revision, which belongs in a bug report. + +## Clean up + +```ts +removeFace(); +human.dispose(); +``` + +`removeFace()` comes first, because the scene still holds the before-render callback that calls +`human.update()`, and every other method throws `TN_MH_DISPOSED` after `dispose()`. + +`dispose()` is idempotent and frees the evaluator. Every other method throws `TN_MH_DISPOSED` +afterwards, so a stale handle fails loudly instead of writing into freed memory. + +## Failures + +Every rejection in this package is a `MetaHumanAssetError` with a stable `code`. Branch on the code, +not the message. + +| Code | What happened | What to do | +| --- | --- | --- | +| `TN_MH_PATH_ESCAPE` | An asset path leaves the asset root | Pass logical paths. `assertAssetPath` rejects absolute paths, drive letters, backslashes and `..` before the path is joined. | +| `TN_MH_SCHEMA` | The sidecar is not a bindings document of the expected schema version | Re-run preparation. `METAHUMAN_BINDINGS_SCHEMA_VERSION` is the version it must declare. | +| `TN_MH_HASH_MISMATCH` | The sidecar's recorded hash differs from the bytes on disk | Regenerate the sidecar from the files you are shipping. | +| `TN_MH_UNKNOWN_JOINT`, `TN_MH_UNKNOWN_NODE`, `TN_MH_UNKNOWN_CHANNEL`, `TN_MH_UNKNOWN_MAP` | The sidecar names something the rig or the GLB does not contain | Re-run preparation against these files. The sidecar and the model are out of step. | +| `TN_MH_INDEX_RANGE` | A joint, morph or LOD index is past the end of its array | Same as above. | +| `TN_MH_BAD_LOD` | The requested LOD is not one the specimen declares | Read the LODs from the sidecar. | +| `TN_MH_UNKNOWN_CONTROL`, `TN_MH_BAD_DOMAIN`, `TN_MH_NON_FINITE`, `TN_MH_DUPLICATE_ALIAS` | The game passed a control the specimen does not declare, or a value outside its domain | Check `human.controls` before writing the call. | +| `TN_MH_ABI`, `TN_MH_WASM_LOAD`, `TN_MH_WASM_CHECKSUM` | The WASM build does not match the ABI, failed to load, or failed its checksum | Do not bypass the checksum. Reinstall the package, or rebuild the binary with the documented toolchain. | + +## Limits + +- The rig's own GUI-to-raw mapping runs. The adapter never re-derives it, which is why a sidecar + that lies about a channel is rejected rather than quietly compensated. +- Nothing is clamped, coerced or smoothed. Every value you pass is the value the rig evaluates. +- Animated maps are read-only to the handle. Binding them to materials is the game's job. +- A native arm changes which evaluator runs and nothing else. It is not a different API, and the + conformance vectors that prove it are run by + `packages/runtime-native/scripts/verify-desktop-metahuman.mjs`, not by this guide. + +## Where this is verified + +`pnpm exec vitest run packages/metahuman` compares the WASM evaluator against the standalone +upstream evaluator on the committed synthetic DNA, drives the binding and its LOD switch against +that rig, and proves two instances stay isolated over ten create/dispose cycles. Two optional lanes +report themselves as skipped, never as passed: `TN_METAHUMAN_SAMPLE_DIR` adds a real `Sample.dna`, +and `TN_METAHUMAN_SPECIMEN_DIR` adds a licensed specimen compared against the exported GLB's rest +transforms. diff --git a/docs/guides/unreal-assets.md b/docs/guides/unreal-assets.md new file mode 100644 index 0000000000..0110163cd2 --- /dev/null +++ b/docs/guides/unreal-assets.md @@ -0,0 +1,202 @@ +# Unreal assets without a conversion step + +Load an Unreal mesh straight from its bytes. Two parsers cover the two file shapes: one reads a raw +editor `.uasset`, the other reads the `.uemodel` that CUE4Parse exports. Neither installs Unreal, and +neither invents geometry it could not read. + +## Choose the parser + +| You have | Use | Why | +| --- | --- | --- | +| An uncooked editor `.uasset` from a Fab pack or your own editor | [`@threenative/raw-unreal`](../../packages/raw-unreal) | Reads the package itself: `FMeshDescription` from UE5.x and UE4.2x, and the UE4.18 `FRawMesh` source model. | +| A `.uemodel` you exported with CUE4Parse or FModel | [`@threenative/ueformat`](../../packages/ueformat) | Reads the interchange format, so LODs, skeleton, sockets, collision and morph targets come with it. | +| Cooked game data, an IoStore container or a PAK archive | Neither | Export from the editor, or re-export with CUE4Parse. | + +The two packages are independent. `ueformat` cannot read a `.uasset`: it accepts a `.uemodel` whose +first eight bytes are `UEFORMAT`, followed by a `UEMODEL` identifier and version byte `10`. +`raw-unreal` reads no `.uemodel` and parses no skeleton. + +## Read the bytes you already have + +Both parsers are pure format layers. They never open a file, so the same call works with bytes from +`fetch`, from Node, or from your own asset pipeline. + +```ts +import { parseUAssetStaticMesh, createThreeObject } from "@threenative/raw-unreal"; + +const bytes = await (await fetch("/assets/SM_pine01.uasset")).arrayBuffer(); +const mesh = createThreeObject(parseUAssetStaticMesh(bytes)); +scene.add(mesh); +``` + +`parseUAssetStaticMesh` returns plain data: `positions`, `normals`, `uvs`, `indices`, `sections`, +`bounds`, `metadata` and `unreal`. Nothing in it is a three.js object, so you can keep the decode and +the adapter apart. `createThreeGeometry(decoded)` gives you the geometry alone when a framework +pipeline owns the materials. + +In the browser, both packages also expose the three.js loader protocol. The loading manager is +optional: + +```ts +import { DefaultLoadingManager } from "three"; +import { UAssetLoader } from "@threenative/raw-unreal"; + +const loader = new UAssetLoader(DefaultLoadingManager); +loader.load("/assets/SM_pine01.uasset", (object) => scene.add(object)); +``` + +`DefaultLoadingManager` is three.js's shared instance, not a class, so pass it as it is. The +constructor argument is optional, and `new UAssetLoader()` creates the loader with that same shared +manager. `UEFormatLoader` is the same shape for a `.uemodel`. Both loaders take `parse(bytes)` for +bytes you +already hold, which is the entry point to prefer outside a browser, because it does not depend on the +fetch path of three.js `FileLoader`. + +`parseUEModel` and `createThreeObject` are the `.uemodel` pair, with one difference worth knowing: +`createThreeGeometry` takes a single LOD there, not a whole model. + +```ts +import { createThreeGeometry, createThreeObject, parseUEModel } from "@threenative/ueformat"; + +const model = parseUEModel(await file.arrayBuffer()); +const hero = createThreeObject(model); // a Group for one LOD, a THREE.LOD for several +const first = createThreeGeometry(model.lods[0]); +``` + +## What each parser accepts + +`raw-unreal` reads legacy-tag uncooked editor packages only. The first four bytes must be the +package tag `0x9e2a83c1`, and three serialized source-model layouts are decoded: + +| Layout | Engine generation | Where the payload lives | +| --- | --- | --- | +| `mesh-description` | UE5.x, named element containers | Inline, or in a UE5 `FCompressedBuffer` trailer payload | +| `mesh-description-ue4` | UE4.2x, fixed-order containers | `FByteBulkData`, usually zlib-compressed at the end of the package | +| `raw-mesh` | UE4.18-era `FRawMesh` | Inline uncompressed, or `FByteBulkData` | + +A bulk payload is read wherever its flags put it: inline, at the end of the package, or in a sibling +`.uexp`, `.ubulk` or `.uptnl` file whose bytes you pass as `bulkDataFiles`. The parser asks for them; +it never fetches them. + +Not read: IoStore `.utoc`/`.ucas`, PAK archives, cooked render buffers, Nanite clusters, skeletal +meshes and skin weights, textures and material graphs. A `.umap` is not reconstructed. + +`ueformat` accepts UEFormat v10 only: the `UEFORMAT` magic, a `UEMODEL` identifier, version byte +`10`, then named attribute sets. Uncompressed and GZIP bodies are handled by the package. It also +parses skeletal mesh data, but it never builds a `SkinnedMesh` and never binds a skeleton: bones, +sockets and skin weights land on `userData` and as `skinIndex`/`skinWeight` attributes, and binding +them stays the game's job. + +## Codecs and export prerequisites + +Neither package bundles a compression codec that carries a licence or a size cost it did not choose. + +| Payload | Codec | Where it comes from | +| --- | --- | --- | +| UE5 `FCompressedBuffer`, Oodle (method 3) | `oodle` | You inject one. `ooz-wasm` works and is GPL-3.0-or-later, so that is your game's licensing decision. | +| UE5 `FCompressedBuffer`, LZ4 (method 4) | `lz4` | You inject one. | +| Editor bulk data, `BULKDATA_SerializeCompressedZLIB` | `zlib` | You inject one. `node:zlib`'s `inflateSync` fits, as do the small browser inflate libraries. | +| `.uemodel` GZIP body | none needed | Bundled through `fflate`, which is why `ueformat` depends on it. | +| `.uemodel` ZSTD body | `zstdDecoder` | You inject one. | + +A payload whose codec you did not supply throws `MISSING_CODEC` (or `INVALID_COMPRESSION` in +`ueformat`) naming the codec. It never guesses, and it never falls back to undecoded bytes. + +```ts +import { inflateSync } from "node:zlib"; +import { parseUAssetStaticMesh } from "@threenative/raw-unreal"; + +const zlib = (compressed: Uint8Array, rawSize: number): Uint8Array => { + const inflated = new Uint8Array(inflateSync(compressed)); + if (inflated.byteLength !== rawSize) throw new Error("inflate size mismatch"); + return inflated; +}; + +const decoded = parseUAssetStaticMesh(bytes, { zlib }); +``` + +## Materials are the game's + +Neither package decides how anything looks. `raw-unreal` gives you one draw group per material +section and leaves the material to `materialFactory`. `ueformat` does the same through +`materialFactory`, plus `coordinateSystem`, `unitScale`, `flipV`, `repairWinding` and `lodDistances`. +The fallback in both is three.js's own plain `MeshStandardMaterial`; a `.uemodel` carries material +slot names and index ranges, not texture payloads, so textures come from your own pipeline. See +[Assets](assets.md) for that side. + +## Inspecting a file before you build from it + +`ueformat` ships a CLI, `ueformat-inspect`, as the bin of `@threenative/ueformat`. Add that package, +then run the binary through `pnpm exec`. + +```sh +pnpm add @threenative/ueformat +pnpm exec ueformat-inspect model.uemodel +pnpm exec ueformat-inspect --json model.uemodel +``` + +Inside this repository, the workspace install already links the binary, so `pnpm exec +ueformat-inspect` is enough. It exits `0` on success, `1` on a `UEFormatError` (printing `CODE: +message (offset N)`) and `2` when no file is given. A ZSTD body cannot be inspected from the CLI, +because the CLI injects no decoder. + +In code, `summarizeUEModel(parseUEModel(bytes))` returns the same summary. `readPackageSummary` +reports which engine generation wrote a `.uasset` before you decode it, and `readPackageLayout` +returns where the export data and bulk-data regions begin, or `undefined` when that walk cannot be +trusted. + +## Limits and safety + +Both parsers validate before they allocate. A candidate offset is trusted only after a parse consumes +its byte range exactly with every count agreeing, and rejected candidates are dropped as byte +patterns rather than reported as defects. + +| Guard | `raw-unreal` | `ueformat` | +| --- | --- | --- | +| Mesh elements | 10,000,000 | `maxArrayElements`, 50,000,000 by default | +| Attribute values | 100,000,000 | `maxAttributes`, 100,000 by default | +| Texture coordinate sets | 8 | no extra cap | +| String length | no length-prefixed strings; the metadata scrape is capped at 256 KiB | `maxStringBytes`, 16 MiB by default | +| Custom versions, generations, chunk ids | 512, 4096, 65536 | not applicable | +| Compression chunk size | 64 MiB | declared size must match exactly | + +`ueformat`'s three limits are yours to change per call through `IParseUEModelOptions`. 64-bit fields +are rejected beyond `Number.MAX_SAFE_INTEGER` instead of silently rounding. + +Two things are best-effort and should be treated as such. `raw-unreal` scrapes `metadata` from the +first 256 KiB of the package by regex, so `engineVersion` and `objectPath` can come back `"unknown"` +or absent; and material slot names are only present when the layout records them. Neither is +authoritative package data. + +## Failures and troubleshooting + +Every parse and geometry failure is a typed error with a code. Catch the class, not the message. + +| Code | Package | What happened | What to do | +| --- | --- | --- | --- | +| `INVALID_PACKAGE_TAG` | `raw-unreal` | The first four bytes are not the legacy package tag | The file is not an uncooked editor `.uasset`. Re-export with CUE4Parse and use `ueformat`. | +| `UNSUPPORTED_STATIC_MESH_LAYOUT` | `raw-unreal` | No inline mesh description, compressed buffer, bulk data or raw mesh blob matched | Read `error.details.probed` and `error.details.supported`: the error names what it looked for, so a cooked or IoStore asset is identifiable from it. | +| `MISSING_CODEC` | `raw-unreal` | A compressed payload needs `oodle`, `lz4` or `zlib` | Inject the codec named in the message. See the table above. | +| `MISSING_BULK_DATA_FILE` | `raw-unreal` | The payload is in a sibling `.ubulk`/`.uptnl` | Read the file the message names and pass it as `bulkDataFiles`. | +| `INVALID_RAW_MESH` | `raw-unreal` | An `FRawMesh` blob does not validate: a version pair the parser does not model, a truncated array, a UV channel that disagrees with the wedge count, or no renderable geometry | Read `error.details`. It carries `offset` plus the count or version that disagreed. A `version` outside `0`/`1`, or a nonzero `licenseeVersion`, means the blob uses a serialized layout this parser does not model, so re-export the model. Every other case means the candidate was not a real `FRawMesh`, so it was rejected as a byte pattern and another blob in the same file may still parse. | +| `INVALID_MAGIC` | `ueformat` | The file does not start with `UEFORMAT` | It is not a `.uemodel`. | +| `UNSUPPORTED_VERSION` | `ueformat` | The version byte is not `10` | Re-export with a CUE4Parse build that writes UEFormat v10. | +| `INVALID_COMPRESSION` | `ueformat` | A ZSTD body, or a compression format this build does not know | Inject `zstdDecoder` in the parse options. The CLI cannot do this. | +| `SIZE_MISMATCH`, `ATTRIBUTE_SIZE_MISMATCH` | `ueformat` | A declared size disagrees with the bytes, or the body does not end where it should | The export is truncated. Re-export it. | +| `INVALID_GEOMETRY` | `ueformat` | Indices, channels or sections disagree with the vertex count | Read `error.offset`. It names the count that did not add up. | + +`raw-unreal` reports a `details` record; `ueformat` reports a byte `offset`. They are not +interchangeable, so log both fields rather than assuming one. + +When a parse fails in a way this table does not cover, [Troubleshooting](troubleshooting.md) lists +the engine-wide diagnostics, and the `raw-unreal` and `ueformat` test suites are the executable +reference for what each layout contains. + +## Where this is verified + +`packages/raw-unreal` holds a committed UE 5.7 editor fixture, `SM_cube.uasset`, and asserts the +decoded geometry, the draw groups and `userData.unreal` against it. `packages/ueformat` ships three +committed `.uemodel` fixtures (a table model, a GZIP table, a rigged GZIP model) and builds +synthetic models to assert coordinate conversion, winding, tangents, morphs and skin attributes. +Neither package has a native-arm conformance case, so the claim that these parsers run on the native +arm rests on their source using only typed arrays, not on a recorded result. diff --git a/packages/create-threenative/agent-docs/references/finding-assets.md b/packages/create-threenative/agent-docs/references/finding-assets.md index cb4bc6386f..e305370db3 100644 --- a/packages/create-threenative/agent-docs/references/finding-assets.md +++ b/packages/create-threenative/agent-docs/references/finding-assets.md @@ -42,16 +42,20 @@ without optional preview dependencies while a complete preview/check loop may no ends**. Poly Haven requires a visible Poly Haven credit when its API is used, ambientCG is CC0 per asset page, and audio and bundle licenses are per pack. -**For a Poly Haven *model*, steps 3-4 collapse into `polyhaven_import_model`.** Poly Haven -publishes models as a `.gltf` beside a `.bin` and loose texture files and **never as a `.glb`**, so -doing it by hand means fetching five to eight URLs and rebuilding the directory layout the glTF's -relative URIs expect before anything will load. Pass `assetId`, an `outputPath` ending in `.glb`, -a `resolution` (it defaults to `1k`, which is what a game floor wants), and `acceptLicense: true`; -it downloads through the same guarded path, packs one self-contained GLB that -`ctx.assets.model()` loads directly, and returns **triangle count and per-texture dimensions** — -the two numbers `create-threenative inspect` does not report and that every triangle and texture -budget is written in. Ask for a resolution the asset does not publish and it names the ones that -exist. Textures and HDRIs still use `asset_download_file`. +**A Poly Haven *model* takes one extra step, and no tool does it for you.** Poly Haven publishes +models as a `.gltf` beside a `.bin` and loose texture files and **never as a `.glb`**, so the file +list is the whole model: call `polyhaven_list_files` with the `assetId`, a `resolution` such as +`1k`, and `includeDependencies: true`, then `asset_download_file` once per entry — the `.gltf` and +every URL the tool reported as a dependency — with `provider: "polyhaven"`, the `url` and `fileName` +the listing gave, and `acceptLicense: true`. Keep the downloaded names exactly as the listing +spelled them: the glTF's relative URIs resolve against them, so a renamed texture loads as a +missing file rather than as an error. Serve that directory from `public/` and load it with +`ctx.assets.model()`. + +No tool packs those files into one self-contained GLB. If you want one, convert the downloaded +`.gltf` in Blender or with `gltf-transform` in a build step, and report the triangle count and +per-texture dimensions yourself — `create-threenative inspect` does not read a Poly Haven listing, +so those two numbers come from the conversion. Textures and HDRIs stay on `asset_download_file`. **Never state a license you did not read off a tool result.** If `polyhaven_list_files` or `ambientcg_search_assets` did not tell you, you do not know it. diff --git a/packages/create-threenative/asset-mcp-tools.json b/packages/create-threenative/asset-mcp-tools.json index 7c1a979423..7b5387981b 100644 --- a/packages/create-threenative/asset-mcp-tools.json +++ b/packages/create-threenative/asset-mcp-tools.json @@ -1,5 +1,5 @@ { - "comment": "The asset MCP surface a generated project actually gets. `tools` is the live tools/list response of the pinned version, recorded by installing threenative-asset-mcp@0.9.5 from the registry into a clean directory containing `.threenative` and driving it over stdio - not copied from its docs. The published 0.9.5 serves all 46 tools, including the humanoid rig inspect, auto-rig, retarget and preview tools. `profile` is null because the server has no profile selector. `recommended` is the loop the template AGENTS.md teaches, not a claim about what the other tools can do - `asset_search_sources` is the authority on that. `fab_import_asset` and `asset_import_unreal` are deliberately not recommended: they convert an Unreal pack the user already owns, which is a route a game takes on purpose rather than a step in the ordinary asset loop. Regenerate with `pnpm tsx scripts/capture-asset-mcp-tools.ts`.", + "comment": "The asset MCP surface a generated project actually gets. `tools` is the live tools/list response of the pinned version, recorded by installing threenative-asset-mcp@0.9.5 from the registry into a clean directory containing `.threenative` and driving it over stdio - not copied from its docs. `inputSchemas` and `descriptions` come from that same response, so the documentation cannot name an argument the server does not accept. The published 0.9.5 serves all 46 tools, including the humanoid rig inspect, auto-rig, retarget and preview tools. `profile` is null because the server has no profile selector. `recommended` is the loop the template AGENTS.md teaches, not a claim about what the other tools can do - `asset_search_sources` is the authority on that. `fab_import_asset` and `asset_import_unreal` are deliberately not recommended: they convert an Unreal pack the user already owns, which is a route a game takes on purpose rather than a step in the ordinary asset loop. Regenerate with `pnpm tsx scripts/capture-asset-mcp-tools.ts`.", "version": "0.9.5", "profile": null, "recommended": [ @@ -63,5 +63,1840 @@ "smithsonian_get_asset", "smithsonian_list_files", "smithsonian_search_assets" - ] + ], + "inputSchemas": { + "creature_status": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "additionalProperties": false + }, + "creature_guide": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "section": { + "type": "string", + "enum": ["overview", "syntax", "low", "mid", "high", "delivery"] + } + }, + "required": ["section"], + "additionalProperties": false + }, + "asset_inspect_rig": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "target": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "object", + "properties": { + "sourceId": { + "type": "string", + "enum": ["aether-02", "aether-02-retopo"], + "description": "Explicitly acquire a pinned sample into the development cache." + } + }, + "required": ["sourceId"] + } + ], + "description": "Absolute path to the humanoid GLB under inspection, or a pinned sample reference to acquire into the development cache." + }, + "libraries": { + "description": "Optional local UAL ZIP archives or GLB libraries used as animation donors.", + "maxItems": 8, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + }, + "required": ["target"] + }, + "asset_auto_rig": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "target": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute path to an unrigged humanoid GLB." + }, + "output": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Output .glb path under projectRoot." + }, + "projectRoot": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Project root that must contain the output." + }, + "weightMode": { + "default": "smooth", + "type": "string", + "enum": ["smooth", "rigid"] + }, + "replaceRig": { + "default": false, + "type": "boolean" + }, + "maxInfluences": { + "type": "integer", + "minimum": 1, + "maximum": 4 + }, + "priorDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "overrides": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + } + }, + "orientation": { + "type": "object", + "properties": { + "up": { + "type": "string", + "enum": ["x", "y", "z"] + }, + "arm": { + "type": "string", + "enum": ["x", "y", "z"] + }, + "facing": { + "type": "string", + "enum": ["x", "y", "z"] + }, + "facingSign": { + "anyOf": [ + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": -1 + } + ] + } + } + } + }, + "required": ["target", "output", "projectRoot"] + }, + "asset_preview_animation": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "prepared": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute path to a prepared (rigged or retargeted) GLB." + }, + "output": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Output contact-sheet .png path under projectRoot." + }, + "projectRoot": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Project root that must contain the output." + }, + "clip": { + "type": "string", + "maxLength": 300 + }, + "times": { + "minItems": 1, + "maxItems": 12, + "type": "array", + "items": { + "type": "number", + "minimum": 0 + } + }, + "pose": { + "type": "object", + "properties": { + "bone": { + "type": "string", + "maxLength": 64 + }, + "axis": { + "type": "string", + "enum": ["x", "y", "z"] + }, + "degrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + } + }, + "required": ["bone", "axis", "degrees"] + }, + "angles": { + "default": 3, + "type": "integer", + "minimum": 2, + "maximum": 6 + }, + "width": { + "default": 384, + "type": "integer", + "minimum": 64, + "maximum": 1024 + }, + "height": { + "default": 384, + "type": "integer", + "minimum": 64, + "maximum": 1024 + }, + "priorDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": ["prepared", "output", "projectRoot"] + }, + "asset_retarget_animations": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "target": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute path to the prepared target GLB (e.g. the AETHER sample)." + }, + "output": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Output .glb path under projectRoot." + }, + "projectRoot": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Project root that must contain the output." + }, + "clips": { + "minItems": 1, + "maxItems": 24, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 300, + "description": "Pinned donor clip id, e.g. ual1/Walk_Loop." + }, + "variant": { + "type": "string", + "enum": ["in_place", "root_motion"], + "description": "Which donor variant to retarget." + } + }, + "required": ["id", "variant"] + } + }, + "keepExistingClips": { + "default": true, + "type": "boolean" + }, + "mapping": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 64 + } + }, + "priorDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": ["target", "output", "projectRoot", "clips"] + }, + "fab_search_assets": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "priceMode": { + "default": "free", + "type": "string", + "enum": ["free", "any", "range"] + }, + "minPrice": { + "type": "number", + "minimum": 0 + }, + "maxPrice": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string", + "pattern": "^[A-Za-z]{3}$" + }, + "channels": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "listingTypes": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "categories": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "tags": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "licenses": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "publisher": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "minimumRating": { + "anyOf": [ + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + }, + { + "type": "number", + "const": 5 + } + ] + }, + "publishedSince": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "aiGenerated": { + "type": "boolean" + }, + "allowsAiUse": { + "type": "boolean" + }, + "sort": { + "default": "relevance", + "type": "string", + "enum": [ + "relevance", + "rating", + "newest", + "oldest", + "price_asc", + "price_desc", + "discount_desc", + "recently_updated" + ] + }, + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 24 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + } + }, + "fab_get_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "listingIdOrUrl": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "currency": { + "type": "string", + "pattern": "^[A-Za-z]{3}$" + } + }, + "required": ["listingIdOrUrl"] + }, + "fab_list_filters": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "kinds": { + "maxItems": 4, + "type": "array", + "items": { + "type": "string", + "enum": ["channels", "listing_types", "categories", "licenses"] + } + }, + "refresh": { + "default": false, + "type": "boolean" + } + } + }, + "fab_list_limited_time_free": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 24 + } + } + }, + "fab_download_free_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "listingIdOrUrl": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "format": { + "type": "string", + "enum": ["blender", "fbx", "glb", "gltf", "maya", "obj", "unity"] + }, + "acceptFabEula": { + "type": "boolean", + "const": true, + "description": "Required acknowledgement that the user accepts the Fab EULA linked by the listing." + } + }, + "required": ["listingIdOrUrl", "format", "acceptFabEula"] + }, + "fab_list_owned": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "description": "Case-insensitive substring of the title or category. Omit to list everything.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "unrealOnly": { + "default": false, + "description": "Keep only what fab_import_asset can actually take: a listing UID plus a published Unreal artifact. Drops the engine installs and code plugins a library also contains.", + "type": "boolean" + } + } + }, + "asset_import_unreal": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "sourceDir": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "A local directory of Unreal .uasset/.umap files, for example an already downloaded Fab pack." + }, + "outputDir": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Directory the source GLBs are written to, normally /assets/fab/. It must not already hold a different import; this tool never overwrites one." + }, + "packages": { + "description": "Import only these package names, for example [\"SM_Rock\", \"DemoMap\"]. Omit to convert every supported mesh and level in the pack; a large marketplace pack is many gigabytes of GLBs.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "maxTextureSize": { + "description": "Optional longest edge for embedded textures. Leave unset to keep Unreal's own resolution and let the ThreeNative asset compiler cap it.", + "type": "integer", + "minimum": 64, + "maximum": 8192 + } + }, + "required": ["sourceDir", "outputDir"] + }, + "fab_import_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "listingIdOrUrl": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "outputDir": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Directory the source GLBs are written to, normally /assets/fab/. It must not already hold a different import; this tool never overwrites one." + }, + "packages": { + "description": "Import only these package names, for example [\"SM_Rock\", \"DemoMap\"]. Omit to convert every supported mesh and level in the pack; a large marketplace pack is many gigabytes of GLBs.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "engine": { + "description": "Unreal engine selector, for example UE_4.21. Required when the listing publishes several artifacts.", + "type": "string", + "pattern": "^UE_\\d+\\.\\d+$" + }, + "maxTextureSize": { + "description": "Optional longest edge for embedded textures. Leave unset to keep Unreal's own resolution and let the ThreeNative asset compiler cap it.", + "type": "integer", + "minimum": 64, + "maximum": 8192 + }, + "platform": { + "description": "Platform build to download when the artifact publishes several, for example Windows.", + "type": "string", + "pattern": "^[A-Za-z0-9_]{1,32}$" + }, + "acceptFabEula": { + "type": "boolean", + "const": true, + "description": "Required acknowledgement that the user accepts the Fab EULA for the listing they already own." + } + }, + "required": ["listingIdOrUrl", "outputDir", "acceptFabEula"] + }, + "polyhaven_search_assets": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "type": { + "default": "all", + "type": "string", + "enum": ["all", "hdris", "textures", "models"] + }, + "categories": { + "default": [], + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "sort": { + "default": "relevance", + "type": "string", + "enum": ["relevance", "popular", "newest", "name"] + }, + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + } + }, + "polyhaven_get_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "assetId": { + "type": "string", + "maxLength": 200, + "pattern": "^[A-Za-z0-9_-]+$" + } + }, + "required": ["assetId"] + }, + "polyhaven_list_categories": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "type": { + "type": "string", + "enum": ["hdris", "textures", "models"] + } + }, + "required": ["type"] + }, + "polyhaven_list_files": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "assetId": { + "type": "string", + "maxLength": 200, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "resolution": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+k$" + }, + "format": { + "type": "string", + "maxLength": 30, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "includeDependencies": { + "default": true, + "type": "boolean" + }, + "limit": { + "default": 100, + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + }, + "required": ["assetId"] + }, + "ambientcg_search_assets": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "type": { + "type": "string", + "enum": [ + "material", + "hdri", + "substance", + "decal", + "atlas", + "3d-model", + "plain-image", + "brush", + "terrain", + "hdri-element" + ] + }, + "sort": { + "default": "popular", + "type": "string", + "enum": ["popular", "latest", "downloads", "oldest", "random", "alphabet"] + }, + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + } + }, + "ambientcg_get_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "assetId": { + "type": "string", + "maxLength": 200, + "pattern": "^[A-Za-z0-9_-]+$" + } + }, + "required": ["assetId"] + }, + "ambientcg_list_categories": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "type": { + "type": "string", + "enum": [ + "material", + "hdri", + "substance", + "decal", + "atlas", + "3d-model", + "plain-image", + "brush", + "terrain", + "hdri-element" + ] + } + } + }, + "ambientcg_list_files": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "assetId": { + "type": "string", + "maxLength": 200, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "attributes": { + "type": "string", + "maxLength": 100 + }, + "extension": { + "type": "string", + "maxLength": 20 + }, + "limit": { + "default": 100, + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + }, + "required": ["assetId"] + }, + "smithsonian_search_assets": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "fileType": { + "type": "string", + "enum": ["jpg", "glb", "ply", "zip"] + }, + "modelType": { + "type": "string", + "enum": ["glb", "ply", "obj", "gltf", "f3z", "blend", "stl"] + }, + "quality": { + "type": "string", + "enum": [ + "Low", + "Medium", + "High", + "Thumb", + "Low_resolution", + "Medium_resolution", + "Full_resolution", + "Water_tight" + ] + }, + "owningUnit": { + "type": "string", + "maxLength": 30, + "pattern": "^[A-Z]+$" + }, + "dracoCompressed": { + "type": "boolean" + }, + "gltfOrientationCompliant": { + "type": "boolean" + }, + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + } + }, + "smithsonian_get_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "modelId": { + "type": "string", + "maxLength": 100, + "pattern": "^(?:3d_package:)?[0-9a-f]{8}-[0-9a-f-]{27}$" + } + }, + "required": ["modelId"] + }, + "smithsonian_list_files": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "modelId": { + "type": "string", + "maxLength": 100, + "pattern": "^(?:3d_package:)?[0-9a-f]{8}-[0-9a-f-]{27}$" + }, + "fileType": { + "type": "string", + "enum": ["jpg", "glb", "ply", "zip"] + }, + "modelType": { + "type": "string", + "enum": ["glb", "ply", "obj", "gltf", "f3z", "blend", "stl"] + }, + "quality": { + "type": "string", + "enum": [ + "Low", + "Medium", + "High", + "Thumb", + "Low_resolution", + "Medium_resolution", + "Full_resolution", + "Water_tight" + ] + }, + "limit": { + "default": 100, + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + }, + "required": ["modelId"] + }, + "sketchfab_search_models": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "downloadable": { + "default": true, + "type": "boolean" + }, + "animated": { + "type": "boolean" + }, + "staffPicked": { + "type": "boolean" + }, + "category": { + "type": "string", + "maxLength": 100, + "pattern": "^[a-z0-9-]+$" + }, + "licenses": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "pattern": "^[a-z0-9-]+$" + } + }, + "sortBy": { + "default": "relevance", + "type": "string", + "enum": ["relevance", "likeCount", "viewCount", "publishedAt"] + }, + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "sketchfab_get_model": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "modelId": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + }, + "required": ["modelId"] + }, + "sketchfab_list_categories": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {} + }, + "sketchfab_get_downloads": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "modelId": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + }, + "required": ["modelId"] + }, + "audio_list_sources": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {} + }, + "audio_search_assets": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "kind": { + "default": "all", + "type": "string", + "enum": ["all", "sfx", "music", "mixed"] + }, + "source": { + "default": "all", + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "enum": [ + "sonniss", + "kenney", + "tallbeard", + "scott-buckley", + "itch-io", + "mixkit", + "pixabay", + "freesound", + "opengameart", + "abstraction" + ] + } + ] + }, + "limit": { + "default": 24, + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "maxLength": 10, + "pattern": "^\\d+$" + } + } + }, + "audio_download_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "assetId": { + "type": "string", + "maxLength": 200, + "pattern": "^[a-z0-9-]+$" + }, + "acceptLicense": { + "type": "boolean", + "const": true, + "description": "Required acknowledgement that the caller reviewed and accepts the asset license returned by audio_search_assets." + } + }, + "required": ["assetId", "acceptLicense"] + }, + "audio_inspect_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "loop": { + "type": "boolean", + "description": "Required: it decides whether the loop seam is checked at all, and a default would silently skip it." + }, + "expectedDurationSeconds": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 3600 + }, + "expectedPrompt": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "alternativePrompts": { + "minItems": 1, + "maxItems": 5, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "emotion": { + "type": "object", + "properties": { + "sourceDescription": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "What is audibly happening, with no emotional adjective: 'ocean waves washing onto a beach'." + }, + "targetMood": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "alternativeMoods": { + "minItems": 1, + "maxItems": 5, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + } + }, + "required": ["sourceDescription", "targetMood", "alternativeMoods"], + "additionalProperties": false + }, + "bands": { + "type": "object", + "properties": { + "sub": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "low": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "mid": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "high": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "air": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "peakMax": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 10 + }, + "silenceRms": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "seamMaxRatio": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000 + }, + "semantic": { + "default": "off", + "type": "string", + "enum": ["off", "clap"] + } + }, + "required": ["path", "loop"], + "additionalProperties": false + }, + "audio_generate_sound": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "requestId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "A UUID you choose. Reusing it returns the saved result instead of generating and charging again." + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "durationSeconds": { + "default": 5, + "type": "number", + "minimum": 0.5, + "maximum": 30 + }, + "loop": { + "default": false, + "type": "boolean" + }, + "promptInfluence": { + "default": 0.3, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "inspection": { + "type": "object", + "properties": { + "expectedPrompt": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "alternativePrompts": { + "minItems": 1, + "maxItems": 5, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "emotion": { + "type": "object", + "properties": { + "sourceDescription": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "What is audibly happening, with no emotional adjective: 'ocean waves washing onto a beach'." + }, + "targetMood": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "alternativeMoods": { + "minItems": 1, + "maxItems": 5, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + } + }, + "required": ["sourceDescription", "targetMood", "alternativeMoods"], + "additionalProperties": false + }, + "bands": { + "type": "object", + "properties": { + "sub": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "low": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "mid": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "high": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + }, + "air": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "max": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "peakMax": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 10 + }, + "silenceRms": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "seamMaxRatio": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000 + }, + "semantic": { + "default": "off", + "type": "string", + "enum": ["off", "clap"] + } + }, + "additionalProperties": false + } + }, + "required": ["requestId", "prompt"], + "additionalProperties": false + }, + "itch_list_downloads": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "packId": { + "type": "string", + "enum": [ + "tallbeard-music-loop-bundle", + "quaternius-universal-animation-library-1", + "quaternius-universal-animation-library-2", + "brackeys-vfx-bundle", + "kaykit-platformer" + ] + } + }, + "required": ["packId"] + }, + "itch_download_asset": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "packId": { + "type": "string", + "enum": [ + "tallbeard-music-loop-bundle", + "quaternius-universal-animation-library-1", + "quaternius-universal-animation-library-2", + "brackeys-vfx-bundle", + "kaykit-platformer" + ] + }, + "uploadId": { + "type": "string", + "maxLength": 30, + "pattern": "^\\d+$" + }, + "acceptLicense": { + "type": "boolean", + "const": true, + "description": "Required acknowledgement that the caller accepts the CC0 terms and any pack-specific cautions returned by itch_list_downloads." + } + }, + "required": ["packId", "uploadId", "acceptLicense"] + }, + "asset_list_bundle_entries": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "packId": { + "type": "string", + "enum": [ + "tallbeard-music-loop-bundle", + "quaternius-universal-animation-library-1", + "quaternius-universal-animation-library-2", + "brackeys-vfx-bundle", + "kaykit-platformer" + ] + }, + "uploadId": { + "type": "string", + "maxLength": 30, + "pattern": "^\\d+$" + } + }, + "required": ["packId", "uploadId"] + }, + "asset_download_bundle_entry": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "packId": { + "type": "string", + "enum": [ + "tallbeard-music-loop-bundle", + "quaternius-universal-animation-library-1", + "quaternius-universal-animation-library-2", + "brackeys-vfx-bundle", + "kaykit-platformer" + ] + }, + "uploadId": { + "type": "string", + "maxLength": 30, + "pattern": "^\\d+$" + }, + "entryPath": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "acceptLicense": { + "type": "boolean", + "const": true, + "description": "Required acknowledgement of the pack license returned by itch_list_downloads or asset_list_bundle_entries." + } + }, + "required": ["packId", "uploadId", "entryPath", "acceptLicense"] + }, + "asset_list_bundle_animations": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "packId": { + "type": "string", + "enum": [ + "tallbeard-music-loop-bundle", + "quaternius-universal-animation-library-1", + "quaternius-universal-animation-library-2", + "brackeys-vfx-bundle", + "kaykit-platformer" + ] + }, + "uploadId": { + "type": "string", + "maxLength": 30, + "pattern": "^\\d+$" + }, + "entryPath": { + "description": "Optional aggregate GLB path. Omit to select the standard non-root-motion GLB automatically.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": ["packId", "uploadId"] + }, + "asset_download_bundle_animation": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "packId": { + "type": "string", + "enum": [ + "tallbeard-music-loop-bundle", + "quaternius-universal-animation-library-1", + "quaternius-universal-animation-library-2", + "brackeys-vfx-bundle", + "kaykit-platformer" + ] + }, + "uploadId": { + "type": "string", + "maxLength": 30, + "pattern": "^\\d+$" + }, + "entryPath": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "animationName": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "acceptLicense": { + "type": "boolean", + "const": true + } + }, + "required": ["packId", "uploadId", "animationName", "acceptLicense"] + }, + "asset_list_sources": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "agentReadyOnly": { + "default": true, + "type": "boolean" + } + } + }, + "asset_search_sources": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "category": { + "default": "all", + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "enum": [ + "3d-model", + "animation", + "texture", + "hdri", + "vfx", + "2d", + "ui", + "icon", + "font", + "sfx", + "music", + "shader", + "directory" + ] + } + ] + }, + "access": { + "default": "all", + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "enum": [ + "mcp-api", + "mcp-curated", + "mcp-marketplace", + "provider-page", + "package-manager", + "git" + ] + } + ] + }, + "license": { + "default": "all", + "anyOf": [ + { + "type": "string", + "const": "all" + }, + { + "type": "string", + "enum": ["cc0", "cc-by", "mit", "open-source", "per-asset", "marketplace"] + } + ] + }, + "agentReadyOnly": { + "default": true, + "type": "boolean" + } + } + }, + "asset_download_file": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "provider": { + "type": "string", + "enum": ["polyhaven", "ambientcg", "smithsonian", "game-icons", "kenney"] + }, + "url": { + "type": "string", + "maxLength": 4096, + "format": "uri" + }, + "fileName": { + "type": "string", + "minLength": 1, + "maxLength": 220 + }, + "acceptLicense": { + "type": "boolean", + "const": true, + "description": "Required acknowledgement that the caller reviewed and accepts the license metadata returned by the provider's search/detail/file tools." + } + }, + "required": ["provider", "url", "fileName", "acceptLicense"] + }, + "creature_compile": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "specPath": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "outputPath": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "expectedOutputSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": ["specPath", "outputPath"], + "additionalProperties": false + }, + "creature_preview": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "glbPath": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "mode": { + "type": "string", + "enum": ["silhouettes", "hero"] + }, + "previousPreviewId": { + "type": "string", + "pattern": "^[a-z0-9-]{8,80}$" + } + }, + "required": ["glbPath", "mode"], + "additionalProperties": false + }, + "creature_check": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "glbPath": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "mode": { + "type": "string", + "const": "structural" + } + }, + "required": ["glbPath", "mode"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "glbPath": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "mode": { + "type": "string", + "const": "claims" + }, + "claimsPath": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "stage": { + "type": "string", + "enum": ["LOW", "MID", "HIGH"] + } + }, + "required": ["glbPath", "mode", "claimsPath", "stage"], + "additionalProperties": false + } + ] + } + }, + "descriptions": { + "creature_status": "Report the pinned anyCreature payload, currently available operations, and optional local tooling without starting a browser or requesting credentials.", + "creature_guide": "Read a bounded syntax or workflow section from the verified anyCreature 1.3.1 payload. This does not run or extract the compiler.", + "asset_inspect_rig": "Inspect a local humanoid GLB and optional local UAL ZIP/GLB libraries: meshes, joints, suggested bone mappings, clips, attributes, extensions, measured bounds and attachment candidates. Reads only caller-selected local files and downloads nothing.", + "asset_auto_rig": "Fit an 18-joint humanoid skeleton to an unrigged local GLB by measured geometry landmarks, bind smooth or rigid skin weights and publish the skinned GLB under the project root. Preserves an existing rig unless replaceRig is set; ambiguous anatomy returns a landmark correction request instead of a bad rig.", + "asset_preview_animation": "Render a prepared GLB from several angles with an optional clip sample time or explicit bone pose, and publish a nonblank contact sheet under the project root. A missing rendering backend returns an explicit unavailable result instead of claiming success.", + "asset_retarget_animations": "Fetch the selected pinned release donors, retarget their clips onto a prepared target skeleton with a world-space rest correction, preserve the target's mesh/materials/UVs and (by default) its existing clips, and publish a game minimal GLB. Root motion requires the matching _RM donor.", + "fab_search_assets": "Search public Fab marketplace listings. Free assets are returned by default. Results carry no file formats and there is no format filter: call fab_get_asset for a listing's formats.", + "fab_get_asset": "Get normalized public details and per-license prices for one Fab listing.", + "fab_list_filters": "List usable public Fab filter labels and slugs. A warning identifies versioned fallback or stale values.", + "fab_list_limited_time_free": "List Fab's dedicated curated limited-time-free promotions separately from general free search.", + "fab_download_free_asset": "Download one directly available free Fab file into the configured local download directory. This never purchases or adds an asset to a library. Every GLB result reports `sizeMeters`, its bounding-box size in the file's own units: a value near 890 for something meant to be 9 m means the source was authored in centimetres and the game must scale it.", + "fab_list_owned": "List every listing already in the signed-in Fab library, optionally filtered to the ones that publish an Unreal artifact fab_import_asset can convert. Search free assets first with fab_search_assets; reach for this when you want something already paid for. Read-only — it never claims or purchases.", + "asset_import_unreal": "Convert a local directory of Unreal .uasset/.umap files into self-contained model and scene GLBs under a game's assets directory, with reconstructed PBR materials, actor transforms, and a provenance report. Works on any already-downloaded Unreal pack, whatever downloaded it. Every GLB result reports `sizeMeters`, its bounding-box size in the file's own units: a value near 890 for something meant to be 9 m means the source was authored in centimetres and the game must scale it.", + "fab_import_asset": "Download an Unreal asset the signed-in Fab account already owns and convert it to source GLBs in one step. Uses the FabCLI session the user established themselves; it never logs in, claims, or purchases anything. Every GLB result reports `sizeMeters`, its bounding-box size in the file's own units: a value near 890 for something meant to be 9 m means the source was authored in centimetres and the game must scale it.", + "polyhaven_search_assets": "Search CC0 HDRIs, textures, and models from Poly Haven. Results include explicit Poly Haven attribution.", + "polyhaven_get_asset": "Get normalized metadata, authorship, dimensions, and CC0 license information for one Poly Haven asset.", + "polyhaven_list_categories": "List Poly Haven category labels and asset counts for HDRIs, textures, or models.", + "polyhaven_list_files": "List official download URLs, byte sizes, MD5 hashes, and file dependencies for a Poly Haven asset, with format and resolution filters.", + "ambientcg_search_assets": "Search CC0 materials, HDRIs, models, decals, atlases, brushes, terrains, and other ambientCG assets.", + "ambientcg_get_asset": "Get normalized metadata and CC0 licensing for one ambientCG asset.", + "ambientcg_list_categories": "List ambientCG category metadata and asset counts.", + "ambientcg_list_files": "List official CC0 asset archives with variant attributes, extensions, URLs, and byte sizes.", + "smithsonian_search_assets": "Search Smithsonian Open Access 3D models and file variants by text, type, quality, and owning unit.", + "smithsonian_get_asset": "Get a normalized summary of one Smithsonian Open Access 3D model.", + "smithsonian_list_files": "List direct Smithsonian Open Access files with format, quality, compression, and orientation metadata.", + "sketchfab_search_models": "Search public Sketchfab models and return per-model license, author, geometry, and archive metadata.", + "sketchfab_get_model": "Get public metadata and explicit Creative Commons requirements for one Sketchfab model.", + "sketchfab_list_categories": "List public Sketchfab model category names and slugs.", + "sketchfab_get_downloads": "Get temporary download URLs for a downloadable Sketchfab model using the user's SKETCHFAB_API_TOKEN. This does not store credentials or download files.", + "audio_list_sources": "List supported audio libraries, best uses, licensing caveats, browse pages, and whether this MCP has a curated direct-download contract.", + "audio_search_assets": "Search the curated catalog of official audio packs with stable direct URLs and explicit license metadata. Use audio_list_sources for broader provider-page discovery.", + "audio_download_asset": "Download one catalog asset by ID into the dedicated audio directory after explicit license acknowledgement. URLs and redirects are restricted to curated official hosts; existing files are never overwritten.", + "audio_inspect_asset": "Measure one local WAV, Ogg, MP3 or FLAC with the pinned threenative-playtest inspector: decode integrity, silence, peak, DC, five-band spectrum, declared-loop seam and requested duration, plus a spectrogram. Needs no ElevenLabs key. Reports technical status, and prompt/emotion fit only when local CLAP is provisioned. It never repairs or rewrites the file, and it never certifies artistic quality: use the findings to decide whether to revise one specific property, then listen before shipping.", + "audio_generate_sound": "SPENDS PROVIDER CREDITS. Generate one sound effect or ambience from a prompt using the user's own ELEVENLABS_API_KEY, save the original response and a PCM16 WAV, and inspect the result automatically. Requires that key in the server environment; without it this tool fails and you should tell the user to set their own. Describe source, action, material, distance and timing in the prompt. Generate once, inspect, revise the specific property that failed, then explicitly generate again — never loop. Reusing a requestId returns the saved result instead of charging again.", + "itch_list_downloads": "Resolve a fresh no-account itch.io download page and list its upload IDs, names, sizes, license, and safe suggested filenames. Signed page tokens are never returned.", + "itch_download_asset": "Resolve a fresh signed itch.io file URL for a listed upload and stream it into guarded local storage after explicit license acknowledgement. Signed URLs are not exposed in the MCP result.", + "asset_list_bundle_entries": "Inspect a curated itch.io ZIP through HTTP byte ranges without downloading the whole archive. Returns individual entry paths and compressed/uncompressed sizes; signed URLs are never exposed.", + "asset_download_bundle_entry": "Extract one selected ZIP entry through HTTP byte ranges, cache it locally, and avoid downloading unrelated bundle contents. Requires explicit license acknowledgement. A `.glb`/`.gltf` entry also reports `sizeMeters`, its bounding-box size in the file's own units: a value near 890 for something meant to be 9 m means the source was authored in centimetres and the game must scale it.", + "asset_list_bundle_animations": "Range-extract and cache only the aggregate GLB from a curated bundle, then return its named animation clips. The standard non-root-motion GLB is selected automatically when entryPath is omitted.", + "asset_download_bundle_animation": "Export one named animation as a small standalone GLB after range-fetching only the aggregate source entry. Unrelated clips, meshes, materials, and textures are removed; cached source entries are reused.", + "asset_list_sources": "List agent-ready sources by default: every result has an MCP download path requiring no manual browser, login, checkout, or paywall. Set agentReadyOnly=false to inspect the broader research directory, including package-manager, Git, authenticated, and provider-page sources.", + "asset_search_sources": "Search agent-ready asset sources by category, license, access mode, or text query. Manual/provider-page sources are excluded unless agentReadyOnly=false.", + "asset_download_file": "Download a file URL returned by Poly Haven, ambientCG, or Smithsonian MCP tools, or a curated Game-icons.net/Kenney direct ZIP, into guarded local storage after license acknowledgement. Provider hosts and URL shapes are allowlisted, redirects are revalidated, files are streamed with a byte cap, and existing files are never overwritten.", + "creature_compile": "Compile a bounded project-relative anyCreature JSON spec with the fixed packaged 1.3.1 compiler, validate its GLB, and publish it atomically with hash-bound evidence.", + "creature_preview": "Render fresh, bounded silhouettes or a hero image for a project-relative creature GLB and return image artifacts with backend and camera identity; visual approval remains independent.", + "creature_check": "Inspect actual GLB mesh, rig, materials and animation bindings, or run validated claims through the fixed anyCreature judge; visual review remains independent." + } } diff --git a/packages/create-threenative/blender-mcp-tools.json b/packages/create-threenative/blender-mcp-tools.json index f6b744e9a5..fbf2b42835 100644 --- a/packages/create-threenative/blender-mcp-tools.json +++ b/packages/create-threenative/blender-mcp-tools.json @@ -20,6 +20,103 @@ "blender_run_python", "blender_status" ], + "inputSchemas": { + "blender_status": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "blender_inspect": { + "additionalProperties": false, + "properties": { + "source": { + "description": "Path to the model file to inspect.", + "type": "string" + } + }, + "required": ["source"], + "type": "object" + }, + "blender_convert": { + "additionalProperties": false, + "properties": { + "out": { + "description": "Path of the .glb to write.", + "type": "string" + }, + "source": { + "description": "Path to the model file to convert.", + "type": "string" + } + }, + "required": ["source", "out"], + "type": "object" + }, + "blender_recipes": { + "additionalProperties": false, + "properties": { + "arguments": { + "description": "Recipe arguments. Present means run it; absent means return its source.", + "type": "object" + }, + "name": { + "description": "Recipe name. Omit to list every recipe.", + "type": "string" + } + }, + "type": "object" + }, + "blender_export_world": { + "additionalProperties": false, + "properties": { + "cell": { + "description": "Cell edge length in metres.", + "type": "number" + }, + "out": { + "description": "Directory to write the package into.", + "type": "string" + }, + "source": { + "description": "Path to the .blend world to read.", + "type": "string" + }, + "spacing": { + "description": "Heightmap sample spacing in metres. Default 2.", + "type": "number" + } + }, + "required": ["source", "out", "cell"], + "type": "object" + }, + "blender_run_python": { + "additionalProperties": false, + "properties": { + "arguments": { + "description": "The JSON request handed to the script after '--'.", + "type": "object" + }, + "script": { + "description": "Path to the .py file to run inside Blender.", + "type": "string" + }, + "timeoutMs": { + "description": "Kill the run after this many milliseconds.", + "type": "number" + } + }, + "required": ["script"], + "type": "object" + } + }, + "descriptions": { + "blender_status": "Report whether this machine has a supported Blender, where it is and what version. Never fails: when Blender is absent it returns available:false with the install command for each platform.", + "blender_inspect": "Report what a model file contains without writing anything: mesh, triangle, vertex and bone counts, material names, animation clip names and image names. Accepts blend, dae, fbx, obj, .gltf and .glb.", + "blender_convert": "Convert a model file to a GLB a ThreeNative game can load, preserving skeletons, animation clips, materials and textures. A conversion that produces no meshes fails rather than writing an empty GLB. Note that a game's build converts these files itself: use this to check a source before committing it, or to write a GLB somewhere else.", + "blender_recipes": "List the shipped bpy recipes, read one's full source text, or run one. Read before you write: a working recipe adapted into your game beats bpy written cold. Omit 'name' to list; pass 'name' alone to read; pass 'name' with 'arguments' to run.", + "blender_export_world": "Export a Blender-authored world as a ThreeNative world package v1: world.json, a raw uint16 heightmap, one GLB plus a decimated LOD1 per referenced scatter asset, per-cell chunk GLBs and placements.bin. Reads render-density instances so viewport share tricks never leak in; marks the terrain with tn_world_terrain, scatter sources with tn_asset_id and chunk collections with tn_world_chunk.", + "blender_run_python": "Run a bpy script of your own inside Blender, with a timeout, returning its result line, stderr and exit code. This is NOT a sandbox and does not claim to be: the script runs with your own privileges, exactly as it would if you invoked Blender through Bash yourself. What it adds is a resolved Blender, the same fail-closed handling every other tool here uses, and discoverability \u2014 not privilege. Read blender_recipes first; the shipped recipes are the worked examples." + }, "statusShape": [ "available", "detail", diff --git a/packages/create-threenative/engine-mcp-tools.json b/packages/create-threenative/engine-mcp-tools.json index 222c59de81..4638ebdd19 100644 --- a/packages/create-threenative/engine-mcp-tools.json +++ b/packages/create-threenative/engine-mcp-tools.json @@ -1,5 +1,40 @@ { "comment": "The engine capability MCP surface shipped through @threenative/core. The golden path also calls a mechanically explicit cross-system request so a packaged consumer proves full-request discovery without repository source.", "version": "0.2.4", - "tools": ["engine_capability_detail", "engine_search_capabilities"] + "tools": ["engine_capability_detail", "engine_search_capabilities"], + "inputSchemas": { + "engine_search_capabilities": { + "additionalProperties": false, + "properties": { + "scope": { + "description": "Use request for the complete cross-system game request; use mechanic or omit it for one mechanic.", + "enum": ["request", "mechanic"], + "type": "string" + }, + "situation": { + "type": "string" + } + }, + "required": ["situation"], + "type": "object" + }, + "engine_capability_detail": { + "additionalProperties": false, + "properties": { + "importPath": { + "description": "The search result's importPath; required when two packages export the same symbol.", + "type": "string" + }, + "symbol": { + "type": "string" + } + }, + "required": ["symbol"], + "type": "object" + } + }, + "descriptions": { + "engine_search_capabilities": "Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope \"request\" for the mechanically explicit full request and \"mechanic\" for each focused search; matchedSituation and score explain every result. The response verdict \"none\" is an actionable answer with guidance, not a failed search.", + "engine_capability_detail": "Inspect one engine capability's import, signature, example, constraints, and overrides. When several packages export the symbol, every match is returned under `matches`." + } } diff --git a/packages/create-threenative/sculpt-mcp-tools.json b/packages/create-threenative/sculpt-mcp-tools.json index b153a3f4a9..b68a4c0ec5 100644 --- a/packages/create-threenative/sculpt-mcp-tools.json +++ b/packages/create-threenative/sculpt-mcp-tools.json @@ -1,5 +1,5 @@ { - "comment": "The sculpt MCP surface a generated project actually gets. `tools` is set-equal to the live tools/list response of version 0.1.1 installed from the published npm tarball into a clean directory; that run also served 31 technique-safe grimoire resources. `recommended` is the complete ordered authoring loop documented by every template.", + "comment": "The sculpt MCP surface a generated project actually gets. `tools` is set-equal to the live tools/list response of version 0.1.1 installed from the published npm tarball into a clean directory; that run also served 31 technique-safe grimoire resources. `inputSchemas` and `descriptions` come from that same response. `recommended` is the complete ordered authoring loop documented by every template. Regenerate with `pnpm tsx scripts/capture-asset-mcp-tools.ts`. The recorded surface serves 5 tools.", "version": "0.1.1", "profile": null, "recommended": [ @@ -15,5 +15,339 @@ "sculpt_pass_gate", "sculpt_plan", "sculpt_spec_gate" - ] + ], + "inputSchemas": { + "sculpt_plan": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "referencePath": { + "type": "string", + "minLength": 1 + }, + "intent": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "required": ["referencePath", "intent"] + }, + "sculpt_spec_gate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "spec": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["spec"] + }, + "sculpt_compare": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "referencePath": { + "type": "string", + "minLength": 1 + }, + "capturePath": { + "type": "string", + "minLength": 1 + }, + "regions": { + "default": [], + "maxItems": 32, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + } + }, + "required": ["id", "x", "y", "width", "height"] + } + } + }, + "required": ["referencePath", "capturePath"] + }, + "sculpt_pass_gate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "passId": { + "type": "string", + "enum": [ + "blockout", + "structural-pass", + "form-refinement", + "material-pass", + "surface-pass", + "lighting-pass", + "interaction-pass", + "optimization-pass" + ] + }, + "compareResult": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "evidence" + }, + "authority": { + "type": "string", + "const": "diagnostic-only" + }, + "referencePath": { + "type": "string" + }, + "capturePath": { + "type": "string" + }, + "dimensions": { + "type": "object", + "properties": { + "reference": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": ["width", "height"] + }, + "capture": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": ["width", "height"] + }, + "normalizedForComparison": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": ["width", "height"] + } + }, + "required": ["reference", "capture", "normalizedForComparison"] + }, + "overall": { + "type": "object", + "properties": { + "score": { + "type": "number" + }, + "confidence": { + "type": "number" + } + }, + "required": ["score", "confidence"] + }, + "regions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "pixels": { + "type": "number" + }, + "score": { + "type": "number" + }, + "confidence": { + "type": "number" + }, + "signals": { + "type": "object", + "properties": { + "structuralSimilarity": { + "type": "number" + }, + "perceptualHashSimilarity": { + "type": "number" + }, + "colorSimilarity": { + "type": "number" + }, + "deltaE00": { + "type": "number" + } + }, + "required": [ + "structuralSimilarity", + "perceptualHashSimilarity", + "colorSimilarity", + "deltaE00" + ] + }, + "corrections": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "pixels", "score", "confidence", "signals", "corrections"] + } + }, + "ambiguous": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "status", + "authority", + "referencePath", + "capturePath", + "dimensions", + "overall", + "regions", + "ambiguous", + "notes" + ] + }, + "semanticReview": { + "type": "object", + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "notes": { + "type": "string", + "minLength": 1 + }, + "criticalFeatures": { + "maxItems": 5, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "critical": { + "type": "boolean" + } + }, + "required": ["id", "score", "threshold", "critical"] + } + } + }, + "required": ["score", "confidence", "notes", "criticalFeatures"] + }, + "threshold": { + "default": 0.7, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "minimumConfidence": { + "default": 0.65, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "attempt": { + "default": 1, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxAttempts": { + "default": 3, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["passId", "compareResult"] + }, + "sculpt_grimoire": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "topic": { + "type": "string", + "minLength": 1 + } + }, + "required": ["topic"] + } + }, + "descriptions": { + "sculpt_plan": "Validate a reference image and return the locked eight-pass sculpt order plus relevant technique-safe grimoire resources.", + "sculpt_spec_gate": "Validate the MCP runtime ObjectSculptSpec contract and canonical complexity depth thresholds before code is written.", + "sculpt_compare": "Produce deterministic diagnostic evidence from an existing capture and reference, globally and per normalized region. Never launches a browser and never authorizes advancement.", + "sculpt_pass_gate": "Return advance, retry, or stop. Semantic review controls advancement; deterministic scores supply corrections, while an explicitly ambiguous comparison blocks advancement.", + "sculpt_grimoire": "Fetch a technique-safe grimoire page by topic. Unknown and concrete shader/material recipe topics fail and list valid topics." + } } diff --git a/packages/raw-unreal/__tests__/guide-example.spec.ts b/packages/raw-unreal/__tests__/guide-example.spec.ts new file mode 100644 index 0000000000..cd48364453 --- /dev/null +++ b/packages/raw-unreal/__tests__/guide-example.spec.ts @@ -0,0 +1,43 @@ +import { readFile } from "node:fs/promises"; +import { decompress as oodle } from "ooz-wasm"; +import { DefaultLoadingManager, Mesh } from "three"; +import { describe, expect, it } from "vitest"; + +import { UAssetLoader } from "../src/index.js"; + +/** + * `docs/guides/unreal-assets.md` tells a reader to hand three.js's shared loading manager to + * `UAssetLoader`. That line is run here, from the guide's own text, because `guide-imports.spec.ts` + * cannot judge it: `DefaultLoadingManager` is a real export, so an import check passes while + * `new DefaultLoadingManager()` still throws. The real shared instance stands in, because + * `new ` is the failure this catches and a plain object would not be one. + */ +const guide = await readFile( + new URL("../../../docs/guides/unreal-assets.md", import.meta.url), + "utf8", +); +const written = [...guide.matchAll(/new\s+UAssetLoader\([^)]*\)/gu)].map((match) => match[0]); +const shared = written.filter((line) => line.includes("DefaultLoadingManager")); + +describe("the Unreal guide's loader example", () => { + it("passes the shared manager as a value, never as a class", () => { + expect(shared).toEqual(["new UAssetLoader(DefaultLoadingManager)"]); + }); + + it("runs that line and parses the committed fixture through it", async () => { + const loader = new Function("DefaultLoadingManager", "UAssetLoader", `return ${shared[0]}`)( + DefaultLoadingManager, + UAssetLoader, + ) as UAssetLoader; + expect(loader.manager).toBe(DefaultLoadingManager); + + // The fixture is an Oodle-compressed UE5 package, so the codec is injected the way the guide's + // codec table says to inject one. + loader.options.parse = { oodle }; + const object = loader.parse( + new Uint8Array(await readFile(new URL("../fixtures/SM_cube.uasset", import.meta.url))), + ); + expect(object).toBeInstanceOf(Mesh); + expect(object.name).toBe("SM_cube"); + }); +}); diff --git a/scripts/__tests__/capability-site-docs.spec.ts b/scripts/__tests__/capability-site-docs.spec.ts new file mode 100644 index 0000000000..8436735966 --- /dev/null +++ b/scripts/__tests__/capability-site-docs.spec.ts @@ -0,0 +1,147 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; +import { jobSections } from "../ci-workflow.js"; + +const REPO = path.resolve(import.meta.dirname, "..", ".."); +const { classify } = await import(new URL("../ci-change-scope.mjs", import.meta.url).href); + +/** The path filters `site-docs.yml` dispatches the site's redeploy on. */ +function dispatchPaths(): readonly string[] { + const source = readFileSync(path.join(REPO, ".github/workflows/site-docs.yml"), "utf8"); + const list = /paths:\n((?: {6}- .*\n)+)/u.exec(source)?.[1] ?? ""; + return list + .split("\n") + .map((line) => + line + .replace(/^ {6}- /u, "") + .trim() + .replace(/^"|"$/gu, ""), + ) + .filter((pattern) => pattern !== ""); +} + +/** GitHub path filters: `*` never crosses a `/`, `**` does. */ +function matches(pattern: string, file: string): boolean { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/gu, "\\$&") + .replaceAll("**", "\u0000") + .replaceAll("*", "[^/]*") + .replaceAll("\u0000", ".*"); + return new RegExp(`^${escaped}$`, "u").test(file); +} + +/** Every module the generator imports, which is every file whose bytes can move the manifest. */ +function generatorImports(): readonly string[] { + const source = readFileSync(path.join(REPO, "scripts/build-capability-manifest.ts"), "utf8"); + return [...source.matchAll(/from "(\.[^"]+)"/gu)].map((match) => + path + .relative(REPO, path.resolve(REPO, "scripts", (match[1] ?? "").replace(/\.js$/u, ".ts"))) + .split(path.sep) + .join("/"), + ); +} + +/** + * One real file from each directory the generator reads beside its imports: an export map, the + * sources it points at, the template closure whose imports must resolve, and the manifests written. + */ +const GENERATED_FROM: readonly string[] = [ + "packages/core/package.json", + "packages/core/src/animation.ts", + "packages/ui/package.json", + "packages/ui/src/index.ts", + "packages/create-threenative/templates/starter/package.json", + "packages/create-threenative/templates/starter/src/game.ts", + "packages/create-threenative/capabilities.json", + "packages/core/capabilities.json", +]; + +function gitFixture() { + const root = makeTempDirSync("threenative-capability-dispatch-"); + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr); + return result.stdout.trim(); + }; + git("init", "-q"); + git("config", "user.email", "capability@example.com"); + git("config", "user.name", "capability"); + writeFileSync(path.join(root, "README.md"), "base\n"); + git("add", "."); + git("commit", "-qm", "base"); + return { + root, + change(file: string) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), "export const changed = 1;\n"); + git("add", "."); + git("commit", "-qm", "change"); + return { base: git("rev-parse", "HEAD~1"), head: git("rev-parse", "HEAD") }; + }, + }; +} + +describe("the capability manifest reaches threenative.com", () => { + it("dispatches the site for every module the manifest generator imports", () => { + const patterns = dispatchPaths(); + expect(patterns.length).toBeGreaterThan(0); + for (const file of generatorImports()) { + expect(existsSync(path.join(REPO, file)), `generator import ${file} moved`).toBe(true); + expect( + patterns.some((pattern) => matches(pattern, file)), + `${file} changes the manifest but site-docs.yml does not dispatch the site for it`, + ).toBe(true); + } + }); + + it("dispatches the site for the package sources, export maps, templates and manifests it reads", () => { + const patterns = dispatchPaths(); + for (const file of GENERATED_FROM) { + expect(existsSync(path.join(REPO, file)), `${file} moved`).toBe(true); + expect( + patterns.some((pattern) => matches(pattern, file)), + `${file} changes the manifest but site-docs.yml does not dispatch the site for it`, + ).toBe(true); + } + }); +}); + +describe("a stale manifest cannot reach develop", () => { + it("runs the freshness gate in a job every selection that can carry a stale manifest reaches", () => { + const ci = readFileSync(path.join(REPO, ".github/workflows/ci.yml"), "utf8"); + const budgets = jobSections(ci).find(([name]) => name === "budgets")?.[1] ?? ""; + // `pnpm budgets` reaches `checkCapabilityManifest`; the manifest spec that also refuses a stale + // copy runs under `full` only, so this job is the gate and it has to cover the other selections. + expect(budgets).toContain("pnpm budgets"); + for (const selection of ["full", "template", "ci"]) { + expect(budgets).toContain(`selection == '${selection}'`); + } + const checkBudgets = readFileSync(path.join(REPO, "scripts/check-budgets.ts"), "utf8"); + expect(checkBudgets).toContain("await capabilityManifestErrors(root)"); + }); + + it.each([ + "packages/core/src/animation.ts", + "packages/create-threenative/templates/starter/package.json", + "scripts/build-capability-manifest.ts", + ])("keeps the freshness gate required for a change to %s", (file) => { + const fixture = gitFixture(); + try { + const { base, head } = fixture.change(file); + const plan = classify({ + root: fixture.root, + base, + head, + candidateSha: head, + target: "develop", + eventName: "pull_request", + }); + expect(plan.jobs.budgets?.required, `${file} selected ${plan.selection}`).toBe(true); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/capture-asset-mcp-tools.spec.ts b/scripts/__tests__/capture-asset-mcp-tools.spec.ts index ffba5b1b2e..31fdbab249 100644 --- a/scripts/__tests__/capture-asset-mcp-tools.spec.ts +++ b/scripts/__tests__/capture-asset-mcp-tools.spec.ts @@ -131,7 +131,11 @@ describe("published asset MCP capture", () => { f.reply({ jsonrpc: "2.0", id: sent.id, result }); }); try { - assert.deepEqual(await listTools(f.child, f.lines, f.next), [...recommended].sort()); + const captured = await listTools(f.child, f.lines, f.next, recommended); + assert.deepEqual(captured.map((tool) => tool.name).sort(), [...recommended].sort()); + // The snapshot's arguments come from this same response, so a tool with no schema here would + // publish a tool page that cannot say what the tool takes. + assert.ok(captured.every((tool) => tool.name.length > 0)); assert.deepEqual(params, [{}, { cursor: "page-2" }]); f.assertClean(); } finally { @@ -157,7 +161,7 @@ describe("published asset MCP capture", () => { f.reply({ jsonrpc: "2.0", id: sent.id, result }); }); try { - await assert.rejects(listTools(f.child, f.lines, f.next), pattern); + await assert.rejects(listTools(f.child, f.lines, f.next, recommended), pattern); f.assertClean(); } finally { f.close(); diff --git a/scripts/__tests__/guide-imports.spec.ts b/scripts/__tests__/guide-imports.spec.ts new file mode 100644 index 0000000000..28620cc9b0 --- /dev/null +++ b/scripts/__tests__/guide-imports.spec.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * A guide that names an export the package does not have is worse than no guide: an agent reads it, + * writes the import, and fails on a symbol that was never shipped. Every `@threenative/*` import in a + * guide or an agent reference must resolve to a real export of that package's entry point. + */ +const PACKAGES = path.resolve("packages"); +const DOCS = [ + path.resolve("docs/guides"), + path.resolve("packages/create-threenative/agent-docs/references"), +]; + +function exportedNames(file: string, seen: Set = new Set()): ReadonlySet { + if (seen.has(file)) return new Set(); + seen.add(file); + const names = new Set(); + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/gu)) { + for (const part of match[1]?.split(",") ?? []) { + const name = part + ?.trim() + .split(/\s+as\s+/u) + .pop() + ?.replace(/^type\s+/u, "") + .trim(); + if (name) names.add(name); + } + } + for (const match of source.matchAll( + /export\s+(?:declare\s+)?(?:abstract\s+)?(?:const|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gu, + )) { + const name = match[1]; + if (name) names.add(name); + } + for (const match of source.matchAll(/export\s+\*\s+from\s+"(\.[^"]+)"/gu)) { + const target = path.resolve(path.dirname(file), match[1]?.replace(/\.js$/u, "") ?? ""); + for (const candidate of [`${target}.ts`, path.join(target, "index.ts")]) { + if (existsSync(candidate)) for (const name of exportedNames(candidate, seen)) names.add(name); + } + } + return names; +} + +/** Package name to the entry point a bare or subpath import resolves to, or nothing. */ +function entryPoint(specifier: string): string | undefined { + const [name = "", ...rest] = specifier.replace("@threenative/", "").split("/"); + for (const directory of readdirSync(PACKAGES)) { + const manifest = path.join(PACKAGES, directory, "package.json"); + if (!existsSync(manifest)) continue; + if ( + (JSON.parse(readFileSync(manifest, "utf8")) as { name?: string }).name !== + `@threenative/${name}` + ) + continue; + const source = path.join(PACKAGES, directory, "src"); + const candidates = rest.length + ? [path.join(source, `${rest.join("/")}.ts`), path.join(source, rest.join("/"), "index.ts")] + : [path.join(source, "index.ts")]; + return candidates.find((candidate) => existsSync(candidate)); + } + return undefined; +} + +function documentedImports(): readonly { + readonly file: string; + readonly specifier: string; + readonly symbol: string; +}[] { + const found: { file: string; specifier: string; symbol: string }[] = []; + for (const directory of DOCS) { + for (const name of readdirSync(directory).sort()) { + if (!name.endsWith(".md")) continue; + const file = path.join(directory, name); + for (const match of readFileSync(file, "utf8").matchAll( + /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s+"(@threenative\/[\w/-]+)"/gu, + )) { + const specifier = match[2] ?? ""; + for (const part of match[1]?.split(",") ?? []) { + const symbol = part + ?.trim() + .split(/\s+as\s+/u)[0] + ?.replace(/^type\s+/u, "") + .trim(); + if (symbol) found.push({ file, specifier, symbol }); + } + } + } + } + return found; +} + +describe("documented engine imports", () => { + it("names only exports the package actually ships", () => { + const imports = documentedImports(); + expect(imports.length).toBeGreaterThan(20); + const unknown: string[] = []; + const cache = new Map>(); + for (const { file, specifier, symbol } of imports) { + const entry = entryPoint(specifier); + const names = entry ? (cache.get(entry) ?? exportedNames(entry)) : undefined; + if (entry && names && !cache.has(entry)) cache.set(entry, names); + if (!names?.has(symbol)) + unknown.push(`${path.relative(process.cwd(), file)}: ${symbol} from ${specifier}`); + } + expect(unknown).toEqual([]); + }); +}); diff --git a/scripts/__tests__/guide-snippets.spec.ts b/scripts/__tests__/guide-snippets.spec.ts new file mode 100644 index 0000000000..2fe65b9a18 --- /dev/null +++ b/scripts/__tests__/guide-snippets.spec.ts @@ -0,0 +1,105 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +/** + * `guide-imports.spec.ts` proves a documented import resolves to a real export. It cannot see the + * mistake this pair of checks is for: `new DefaultLoadingManager()` names a real three.js export and + * is still wrong, because that export is an instance, not a class. So the guide's own text is the + * input here: every snippet is typechecked against the real packages. The loader line the Unreal + * guide writes is then run against the committed fixture by `packages/raw-unreal/__tests__/ + * guide-example.spec.ts`, which sits beside the fixture and the codec that fixture needs. + */ +const GUIDES = ["docs/guides/unreal-assets.md", "docs/guides/metahuman.md"] as const; + +/** Snippets legitimately lean on the scene and loader context a guide never imports. */ +const CONTEXT_CODES = new Set([2304, 2552]); + +function snippets(file: string): readonly string[] { + return [...readFileSync(file, "utf8").matchAll(/^```ts\n([\s\S]*?)^```/gmu)].map( + (match) => match[1] ?? "", + ); +} + +/** `@threenative/x` to this repository's source entry, so a snippet resolves without a build. */ +function workspacePaths(): Record { + const paths: Record = {}; + for (const directory of readdirSync("packages")) { + const manifest = path.join("packages", directory, "package.json"); + if (!existsSync(manifest)) continue; + const name = (JSON.parse(readFileSync(manifest, "utf8")) as { name?: string }).name; + if (!name?.startsWith("@threenative/")) continue; + const entry = path.join("packages", directory, "src", "index.ts"); + if (existsSync(entry)) paths[name] = [entry]; + } + return paths; +} + +/** The package whose own `node_modules` the snippet needs, which is where its virtual file lives. */ +function hostPackage(source: string): string { + const imported = /from\s+"@threenative\/([\w-]+)"/u.exec(source)?.[1]; + if (!imported) return "core"; + for (const directory of readdirSync("packages")) { + const manifest = path.join("packages", directory, "package.json"); + if (!existsSync(manifest)) continue; + // `workspacePaths` keys on the manifest's scoped name, so this compares like with like. + if ( + (JSON.parse(readFileSync(manifest, "utf8")) as { name?: string }).name === + `@threenative/${imported}` + ) + return directory; + } + return "core"; +} + +function typecheck(guide: string, index: number, source: string): readonly string[] { + const directory = `packages/${hostPackage(source)}/.guide-snippets`; + const file = path.join(directory, `${path.basename(guide, ".md")}-${index}.ts`); + const options: ts.CompilerOptions = { + lib: ["lib.es2023.d.ts", "lib.dom.d.ts"], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2023, + types: ["node"], + baseUrl: ".", + paths: workspacePaths(), + }; + // An in-memory host keeps the guide the only source of the code: the file never lands on disk, so a + // passing run cannot come from a stale copy beside the markdown. + const host = ts.createCompilerHost(options, true); + const read = host.readFile.bind(host); + host.readFile = (name) => (path.resolve(name) === path.resolve(file) ? source : read(name)); + host.fileExists = ( + (exists) => (name: string) => + path.resolve(name) === path.resolve(file) || exists(name) + )(host.fileExists.bind(host)); + const program = ts.createProgram([file], options, host); + return program + .getSemanticDiagnostics() + .filter((diagnostic) => !CONTEXT_CODES.has(diagnostic.code)) + .filter((diagnostic) => diagnostic.file?.fileName.endsWith(path.basename(file))) + .map((diagnostic) => { + const where = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, " "); + return `${path.basename(guide)} snippet ${index} line ${(where?.line ?? 0) + 1}: ${message}`; + }); +} + +describe("documented guide snippets", () => { + const pairs = GUIDES.flatMap((guide) => + snippets(guide).map((source, index) => ({ guide, index: index + 1, source })), + ); + + it("finds the snippets it checks", () => { + expect(pairs.length).toBeGreaterThan(5); + }); + + it("typechecks every snippet against the real packages", () => { + const failures = pairs.flatMap(({ guide, index, source }) => typecheck(guide, index, source)); + expect(failures).toEqual([]); + }); +}); diff --git a/scripts/__tests__/mcp-tool-schemas.spec.ts b/scripts/__tests__/mcp-tool-schemas.spec.ts new file mode 100644 index 0000000000..2be2c5c38e --- /dev/null +++ b/scripts/__tests__/mcp-tool-schemas.spec.ts @@ -0,0 +1,117 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { TOOL_DEFINITIONS as BLENDER_TOOLS } from "../../packages/blender-mcp/src/index.js"; +import { toolDefinitions as engineTools } from "../../packages/engine-mcp/src/index.js"; + +/** + * The recorded MCP surfaces are what the docs site publishes, so their arguments must be the ones the + * server actually serves. A snapshot that lists tool names alone cannot answer what a tool takes; a + * snapshot whose schema has drifted from the server is worse, because it documents arguments that + * are rejected. These two servers ship their source here, so the snapshot is compared to it. The asset + * and sculpt servers are published npm packages with no source in this repository: their recorded + * names are checked against the live `tools/list` by `verify-golden-path.ts` instead. + */ +const SURFACES = [ + { file: "packages/create-threenative/engine-mcp-tools.json", tools: engineTools() }, + { file: "packages/create-threenative/blender-mcp-tools.json", tools: BLENDER_TOOLS }, +] as const; + +const SNAPSHOT_DIR = "packages/create-threenative"; +const DOC_DIRECTORIES = [ + "docs/guides", + "packages/create-threenative/agent-docs/references", + "packages/create-threenative/templates", +] as const; + +interface RecordedSurface { + readonly tools: readonly string[]; + readonly inputSchemas?: Readonly>; + readonly descriptions?: Readonly>; +} + +describe("recorded MCP tool schemas", () => { + for (const surface of SURFACES) { + it(`${path.basename(surface.file)} records what each served tool takes`, () => { + const recorded = JSON.parse(readFileSync(surface.file, "utf8")) as RecordedSurface; + expect(recorded.tools.slice().sort()).toEqual(surface.tools.map((tool) => tool.name).sort()); + for (const tool of surface.tools) { + expect(recorded.inputSchemas?.[tool.name], `${tool.name} has no recorded schema`).toEqual( + tool.inputSchema, + ); + expect(recorded.descriptions?.[tool.name], `${tool.name} has no recorded description`).toBe( + tool.description, + ); + } + }); + } + + it("records the arguments an agent has to get right", () => { + const blender = JSON.parse( + readFileSync("packages/create-threenative/blender-mcp-tools.json", "utf8"), + ) as { inputSchemas: Readonly> }; + // `blender_convert` without a `source` is the mistake the docs exist to prevent. + expect(blender.inputSchemas.blender_convert?.required).toContain("source"); + expect(blender.inputSchemas.blender_status?.required).toBeUndefined(); + }); + + // The asset and sculpt servers are published npm packages, so nothing here compares their snapshot + // to a served response — `verify-golden-path.ts` does that. What is checked here is the contract + // the site relies on: every tool in the snapshot carries the arguments it takes, so no page has to + // describe an argument by hand. + for (const name of ["asset-mcp-tools.json", "sculpt-mcp-tools.json"]) { + it(`${name} records an argument list for every tool it lists`, () => { + const snapshot = JSON.parse( + readFileSync(path.join(SNAPSHOT_DIR, name), "utf8"), + ) as RecordedSurface; + expect(snapshot.tools.length).toBeGreaterThan(0); + for (const tool of snapshot.tools) { + expect(snapshot.inputSchemas?.[tool], `${tool} has no recorded schema`).toBeDefined(); + // `not.toBe("")` also passes `undefined`, so the string itself is what is asserted. + expect(snapshot.descriptions?.[tool], `${tool} has no recorded description`).toEqual( + expect.stringMatching(/\S/u), + ); + } + // `acceptLicense` is the acknowledgement the recipes tell an agent to pass, so a snapshot + // without it could not answer the question they ask. + if (snapshot.tools.includes("asset_download_file")) { + expect( + (snapshot.inputSchemas?.asset_download_file as { required?: readonly string[] }).required, + ).toContain("acceptLicense"); + } + }); + } + + it("names only tools a recorded surface actually serves", () => { + const served = new Set(); + for (const name of readdirSync(SNAPSHOT_DIR)) { + if (!name.endsWith("-mcp-tools.json")) continue; + const snapshot = JSON.parse(readFileSync(path.join(SNAPSHOT_DIR, name), "utf8")) as { + tools: readonly string[]; + }; + for (const tool of snapshot.tools) served.add(tool); + } + // A tool call reads `__`, and every `` prefix is one the recorded + // surfaces serve. Anchoring on that prefix keeps config keys such as `tn_lod_distance` and file + // names such as `dutch_ship_medium` out of the check, so an invented `polyhaven_import_model` + // fails it and nothing else does. + const prefixes = new Set([...served].map((tool) => tool.split("_")[0] ?? "")); + const invented: string[] = []; + for (const directory of DOC_DIRECTORIES) { + for (const entry of readdirSync(directory, { encoding: "utf8", recursive: true })) { + const file = path.join(directory, entry); + if (!/\.(?:md|json|ts|mjs)$/u.test(entry) || !statSync(file).isFile()) continue; + for (const match of readFileSync(file, "utf8").matchAll( + /`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)`/gu, + )) { + const name = match[1] ?? ""; + const parts = name.split("_"); + if (parts.length >= 3 && prefixes.has(parts[0] ?? "") && !served.has(name)) { + invented.push(`${file}: ${name}`); + } + } + } + } + expect(invented).toEqual([]); + }); +}); diff --git a/scripts/capture-asset-mcp-tools.ts b/scripts/capture-asset-mcp-tools.ts index 8bf196ea7a..88da0911a6 100644 --- a/scripts/capture-asset-mcp-tools.ts +++ b/scripts/capture-asset-mcp-tools.ts @@ -1,9 +1,9 @@ #!/usr/bin/env tsx -// Writes `packages/create-threenative/asset-mcp-tools.json` from the *published* -// `threenative-asset-mcp` installed into a clean directory — never from a workspace checkout, and -// never from its docs. A snapshot read off source is the object asserting about itself: it agrees -// with the code by construction and would keep agreeing after a packaging mistake made the -// shipped server serve nothing at all. +// Writes `packages/create-threenative/*-mcp-tools.json` for the servers this repository publishes as +// npm packages, from the *published* package installed into a clean directory — never from a +// workspace checkout, and never from its docs. A snapshot read off source is the object asserting +// about itself: it agrees with the code by construction and would keep agreeing after a packaging +// mistake made the shipped server serve nothing at all. // // pnpm tsx scripts/capture-asset-mcp-tools.ts import { type ChildProcessWithoutNullStreams, execFileSync, spawn } from "node:child_process"; @@ -13,21 +13,51 @@ import path from "node:path"; import { createInterface } from "node:readline"; const REPO = path.resolve(import.meta.dirname, ".."); -const SNAPSHOT = path.join(REPO, "packages/create-threenative/asset-mcp-tools.json"); -const PACKAGE = "threenative-asset-mcp"; -const RECOMMENDED = [ - "ambientcg_list_files", - "ambientcg_search_assets", - "asset_download_file", - "asset_search_sources", - "audio_download_asset", - "audio_search_assets", - "polyhaven_list_files", - "polyhaven_search_assets", - "asset_inspect_rig", - "asset_auto_rig", - "asset_retarget_animations", - "asset_preview_animation", +const SNAPSHOT_DIR = path.join(REPO, "packages/create-threenative"); + +/** One published server this repository pins and documents. */ +interface PublishedServer { + readonly package: string; + readonly snapshot: string; + readonly recommended: readonly string[]; + /** What the recorded surface is, and what `recommended` is and is not. */ + readonly comment: (version: string, tools: number) => string; +} + +const SERVERS: readonly PublishedServer[] = [ + { + package: "threenative-asset-mcp", + snapshot: "asset-mcp-tools.json", + recommended: [ + "ambientcg_list_files", + "ambientcg_search_assets", + "asset_download_file", + "asset_search_sources", + "audio_download_asset", + "audio_search_assets", + "polyhaven_list_files", + "polyhaven_search_assets", + "asset_inspect_rig", + "asset_auto_rig", + "asset_retarget_animations", + "asset_preview_animation", + ], + comment: (version, tools) => + `The asset MCP surface a generated project actually gets. \`tools\` is the live tools/list response of the pinned version, recorded by installing ${"threenative-asset-mcp"}@${version} from the registry into a clean directory containing \`.threenative\` and driving it over stdio - not copied from its docs. \`inputSchemas\` and \`descriptions\` come from that same response, so the documentation cannot name an argument the server does not accept. The published ${version} serves all ${tools} tools, including the humanoid rig inspect, auto-rig, retarget and preview tools. \`profile\` is null because the server has no profile selector. \`recommended\` is the loop the template AGENTS.md teaches, not a claim about what the other tools can do - \`asset_search_sources\` is the authority on that. \`fab_import_asset\` and \`asset_import_unreal\` are deliberately not recommended: they convert an Unreal pack the user already owns, which is a route a game takes on purpose rather than a step in the ordinary asset loop. Regenerate with \`pnpm tsx scripts/capture-asset-mcp-tools.ts\`.`, + }, + { + package: "threenative-sculpt-mcp", + snapshot: "sculpt-mcp-tools.json", + recommended: [ + "sculpt_plan", + "sculpt_spec_gate", + "sculpt_compare", + "sculpt_pass_gate", + "sculpt_grimoire", + ], + comment: (version, tools) => + `The sculpt MCP surface a generated project actually gets. \`tools\` is set-equal to the live tools/list response of version ${version} installed from the published npm tarball into a clean directory; that run also served 31 technique-safe grimoire resources. \`inputSchemas\` and \`descriptions\` come from that same response. \`recommended\` is the complete ordered authoring loop documented by every template. Regenerate with \`pnpm tsx scripts/capture-asset-mcp-tools.ts\`. The recorded surface serves ${tools} tools.`, + }, ] as const; const scratch: string[] = []; @@ -119,9 +149,11 @@ export async function listTools( child: ChildProcessWithoutNullStreams, lines: ReturnType, next: { value: number }, -): Promise { + required: readonly string[], +): Promise { const names = new Set(); const cursors = new Set(); + const tools: { name: string; description?: unknown; inputSchema?: unknown }[] = []; let cursor: string | undefined; // `for (;;)` rather than `do … while (true)`: the exit is the `break` on a missing cursor, and // biome rejects the constant condition. @@ -144,6 +176,7 @@ export async function listTools( throw new Error("MCP tools/list: invalid or duplicate tool name"); } names.add(tool.name); + tools.push(tool as { name: string; description?: unknown; inputSchema?: unknown }); } const nextCursor = listed.nextCursor; if (nextCursor === undefined) break; @@ -153,39 +186,42 @@ export async function listTools( cursors.add(nextCursor); cursor = nextCursor; } - const missing = RECOMMENDED.filter((name) => !names.has(name)); + const missing = required.filter((name) => !names.has(name)); if (missing.length) throw new Error(`MCP tools/list: missing recommended tools: ${missing.join(", ")}`); - return [...names].sort(); + return tools; } -function versionPin(): string { +function versionPin(server: PublishedServer): string { const manifest = JSON.parse( readFileSync(path.join(REPO, "packages/core/package.json"), "utf8"), ) as { dependencies?: Record }; - const version = manifest.dependencies?.[PACKAGE]; - if (!version) throw new Error(`TN_ASSET_SNAPSHOT: core does not pin ${PACKAGE}.`); + const version = manifest.dependencies?.[server.package]; + if (!version) throw new Error(`TN_ASSET_SNAPSHOT: core does not pin ${server.package}.`); return version; } -function installPublished(version: string): string { +function installPublished(server: PublishedServer, version: string): string { const root = mkdtempSync(path.join(tmpdir(), "tn-asset-install-")); scratch.push(root); writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "probe", private: true })); mkdirSync(path.join(root, ".threenative")); - execFileSync("npm", ["install", "--no-audit", "--no-fund", `${PACKAGE}@${version}`], { + execFileSync("npm", ["install", "--no-audit", "--no-fund", `${server.package}@${version}`], { cwd: root, stdio: "inherit", }); return root; } -async function main(): Promise { +async function capture(server: PublishedServer): Promise { + const PACKAGE = server.package; + const SNAPSHOT = path.join(SNAPSHOT_DIR, server.snapshot); + const RECOMMENDED = server.recommended; let child: ChildProcessWithoutNullStreams | undefined; let lines: ReturnType | undefined; try { - const version = versionPin(); - const install = installPublished(version); + const version = versionPin(server); + const install = installPublished(server, version); const installed = path.join(install, "node_modules", PACKAGE); const manifest = JSON.parse(readFileSync(path.join(installed, "package.json"), "utf8")) as { bin: Record; @@ -207,16 +243,26 @@ async function main(): Promise { protocolVersion: "2025-06-18", }); child.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n'); - const tools = await listTools(child, lines, next); + const listedTools = await listTools(child, lines, next, RECOMMENDED); + const tools = listedTools.map((tool) => tool.name).sort(); + // The schemas come from the same live response, so no doc restates an argument by hand. + const inputSchemas = Object.fromEntries( + listedTools.map((tool) => [tool.name, tool.inputSchema ?? {}]), + ); + const descriptions = Object.fromEntries( + listedTools.map((tool) => [tool.name, tool.description ?? ""]), + ); const snapshot = { - comment: `The asset MCP surface a generated project actually gets. \`tools\` is the live tools/list response of the pinned version, recorded by installing ${PACKAGE}@${version} from the registry into a clean directory containing \`.threenative\` and driving it over stdio - not copied from its docs. The published ${version} serves all ${tools.length} tools, including the humanoid rig inspect, auto-rig, retarget and preview tools. \`profile\` is null because the server has no profile selector. \`recommended\` is the loop the template AGENTS.md teaches, not a claim about what the other tools can do - \`asset_search_sources\` is the authority on that. \`fab_import_asset\` and \`asset_import_unreal\` are deliberately not recommended: they convert an Unreal pack the user already owns, which is a route a game takes on purpose rather than a step in the ordinary asset loop. Regenerate with \`pnpm tsx scripts/capture-asset-mcp-tools.ts\`.`, + comment: server.comment(version, tools.length), version, profile: null, recommended: [...RECOMMENDED], tools, + inputSchemas, + descriptions, }; writeFileSync(SNAPSHOT, `${JSON.stringify(snapshot, null, 2)}\n`); - process.stdout.write(`asset MCP surface: ${tools.length} tool(s) -> ${SNAPSHOT}\n`); + process.stdout.write(`${PACKAGE} surface: ${tools.length} tool(s) -> ${SNAPSHOT}\n`); } finally { child?.kill(); lines?.close(); @@ -224,6 +270,10 @@ async function main(): Promise { } } +async function main(): Promise { + for (const server of SERVERS) await capture(server); +} + if ( process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname) diff --git a/scripts/capture-blender-mcp-tools.ts b/scripts/capture-blender-mcp-tools.ts index 52c1d6dc25..51c969ea2f 100644 --- a/scripts/capture-blender-mcp-tools.ts +++ b/scripts/capture-blender-mcp-tools.ts @@ -103,7 +103,19 @@ async function main(): Promise { arguments: {}, name: "blender_status", }); - const tools = (listed.tools as { name: string }[]).map((tool) => tool.name).sort(); + // The schemas come from the same live response, so the docs never restate an argument by hand. + const listedTools = listed.tools as readonly { + name: string; + description?: unknown; + inputSchema?: unknown; + }[]; + const tools = listedTools.map((tool) => tool.name).sort(); + const inputSchemas = Object.fromEntries( + listedTools.map((tool) => [tool.name, tool.inputSchema ?? {}]), + ); + const descriptions = Object.fromEntries( + listedTools.map((tool) => [tool.name, tool.description ?? ""]), + ); const statusText = (status.content as { text: string }[])[0]?.text ?? "{}"; const snapshot = { comment: `The blender MCP surface a generated project actually gets. \`tools\` is set-equal to the live tools/list response of version ${manifest.version}, packed from this workspace and installed from that tarball into a clean directory — never read from src/. \`recommended\` is the ordered loop the shipped agent docs describe. Regenerate with \`pnpm tsx scripts/capture-blender-mcp-tools.ts\`.`, @@ -111,6 +123,8 @@ async function main(): Promise { serverInfo: initialized.serverInfo, recommended: [...RECOMMENDED], tools, + inputSchemas, + descriptions, statusShape: Object.keys(JSON.parse(statusText) as Record).sort(), }; writeFileSync(SNAPSHOT, `${JSON.stringify(snapshot, null, 2)}\n`);