diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02e131c238..394de13c29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,10 @@ jobs: with: # Manual full qualification does not consult history; automatic scope selection does. fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 1 || 0 }} + # Scope reads commits and trees, never old file contents, so it fetches no blobs: every + # branch of the 3.9 GB repository took over this job's 5 minutes on a loaded tn-local + # runner (three cancels on PR #574), against 8 s and 20 MB blob-less with the same plan. + filter: blob:none # Until protected develop exists, retain the qualified-main scheduled flow. ref: ${{ github.event_name == 'schedule' && vars.TN_DEVELOP_CI_ENABLED == 'true' && 'develop' || github.sha }} - id: classify diff --git a/packages/animation-mcp/src/ops/log.ts b/packages/animation-mcp/src/ops/log.ts index a95bfcf9ff..6cefe95621 100644 --- a/packages/animation-mcp/src/ops/log.ts +++ b/packages/animation-mcp/src/ops/log.ts @@ -150,6 +150,7 @@ export function upgradeLog(value: unknown): AnimationLog { return { host: value.host, ops: value.ops.map((entry) => op(entry)), version: 4 }; } if (value.version === 3 || value.version === 2) + // quality-allow: a v2 or v3 recipe is Pose Lab's own output and the version check above picks its shape; a malformed one throws in fromRecipe and is never rewritten. return fromRecipe(migrateRecipe(value as unknown as Parameters[0])); throw new Error(`Unsupported animation log version '${String(value.version)}'.`); } diff --git a/packages/create-threenative/animation-mcp-tools.json b/packages/create-threenative/animation-mcp-tools.json new file mode 100644 index 0000000000..416f315a2c --- /dev/null +++ b/packages/create-threenative/animation-mcp-tools.json @@ -0,0 +1,167 @@ +{ + "comment": "The animation MCP surface a generated project actually gets. `tools` is set-equal to the live tools/list response of version 0.1.3, 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 animation`.", + "version": "0.1.3", + "serverInfo": { + "name": "threenative-animation", + "version": "0.1.3" + }, + "recommended": ["animation_inspect", "animation_attach", "animation_open_view"], + "tools": ["animation_attach", "animation_inspect", "animation_open_view"], + "inputSchemas": { + "animation_inspect": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "file": { + "type": "string", + "minLength": 1 + } + }, + "required": ["file"] + }, + "animation_attach": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "anchor": { + "default": "right-hand", + "type": "string", + "enum": ["right-hand", "left-hand", "head", "hips", "right-foot", "left-foot"] + }, + "attached": { + "type": "string", + "minLength": 1 + }, + "bone": { + "type": "string", + "minLength": 1 + }, + "host": { + "type": "string", + "minLength": 1 + }, + "keys": { + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["attached", "detached"] + }, + "clip": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "time": { + "type": "number", + "minimum": 0, + "maximum": 600 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + } + }, + "required": ["position", "rotation", "scale"] + } + }, + "required": ["clip", "time", "transform"] + } + }, + "log": { + "type": "string", + "minLength": 1 + }, + "output": { + "type": "string", + "minLength": 1 + }, + "recipe": { + "type": "string", + "minLength": 1 + } + } + }, + "animation_open_view": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "anchor": { + "default": "right-hand", + "type": "string", + "enum": ["right-hand", "left-hand", "head", "hips", "right-foot", "left-foot"] + }, + "attach": { + "type": "string", + "minLength": 1 + }, + "host": { + "type": "string", + "minLength": 1 + }, + "output": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "project": { + "type": "string", + "minLength": 1 + } + } + } + }, + "descriptions": { + "animation_inspect": "Inspect a local rigged GLB: bones, skeleton landmarks, clips with durations, root-motion travel and in-place detection, meshes, skins and extensions. Reads one local file and writes nothing.", + "animation_attach": "Attach a prop GLB to a bone, with per-clip keyed offsets, and bake it into an ordinary GLB beside the source. Writes the v4 op log (.anim.json) next to the source GLB and the baked GLB; a version 3 pose recipe loads as the same ops and still bakes.", + "animation_open_view": "Start the animation calibrator page on 127.0.0.1 and return its URL. A human drags the prop into place there; the same operations an agent calls are what the page records." + } +} diff --git a/packages/create-threenative/src/doctor.ts b/packages/create-threenative/src/doctor.ts index 58720a5ae4..f907412ac6 100644 --- a/packages/create-threenative/src/doctor.ts +++ b/packages/create-threenative/src/doctor.ts @@ -213,6 +213,7 @@ const CORE_PACKAGE_VERSION = "0.3.6"; const CORE_RESOLVED_SERVERS: ReadonlySet = new Set([ "threenative-engine", "threenative-blender", + "threenative-animation", ]); export const MCP_SERVER_SPECS: readonly IMcpServerSpec[] = Object.entries(MCP_SERVERS).map( diff --git a/packages/runtime-native/__tests__/profile-production.spec.ts b/packages/runtime-native/__tests__/profile-production.spec.ts index 34ae026dbe..ca9cefb741 100644 --- a/packages/runtime-native/__tests__/profile-production.spec.ts +++ b/packages/runtime-native/__tests__/profile-production.spec.ts @@ -319,3 +319,20 @@ describe("production profile staging", () => { ); }); }); + +describe("production package flags", () => { + // The macOS desktop-core collector refused threenative-animation-mcp with + // TN_PROD_PACKAGE_FLAG_UNSUPPORTED: its copy of the flag rule named two MCP packages by hand. + it("passes every workspace package to the scaffolder under the shared flag rule", async () => { + const { packageSourceFlag } = (await import( + new URL("../scripts/profile-production.mjs", import.meta.url).href + )) as { packageSourceFlag(name: string): string }; + const { workspacePackages, workspacePackageSourceFlag } = await import( + "../../../scripts/workspace-packages.js" + ); + const names = workspacePackages().map(({ name }) => name); + expect(names).toContain("threenative-animation-mcp"); + for (const name of names) + expect(packageSourceFlag(name), name).toBe(workspacePackageSourceFlag(name)); + }); +}); diff --git a/packages/runtime-native/scripts/profile-production.mjs b/packages/runtime-native/scripts/profile-production.mjs index 42e525d98b..2024d58ed3 100644 --- a/packages/runtime-native/scripts/profile-production.mjs +++ b/packages/runtime-native/scripts/profile-production.mjs @@ -478,14 +478,14 @@ async function packLocalPackage(directory, destination) { return join(destination, archive); } -function packageSourceFlag(name) { +export function packageSourceFlag(name) { if (name === 'create-threenative') return '--cli-package'; - if (name === 'threenative-blender-mcp') return '--blender-mcp-package'; - if (name === 'threenative-engine-mcp') return '--engine-mcp-package'; if (name.startsWith('@threenative/')) { const suffix = name.slice('@threenative/'.length); return `--threenative-${suffix}-package`; } + // scripts/workspace-packages.ts workspacePackageSourceFlag: an unscoped tool package drops its prefix. + if (name.startsWith('threenative-')) return `--${name.slice('threenative-'.length)}-package`; throw new Error(`TN_PROD_PACKAGE_FLAG_UNSUPPORTED: ${name}`); } diff --git a/scripts/__tests__/mcp-tool-schemas.spec.ts b/scripts/__tests__/mcp-tool-schemas.spec.ts index 2be2c5c38e..282742f234 100644 --- a/scripts/__tests__/mcp-tool-schemas.spec.ts +++ b/scripts/__tests__/mcp-tool-schemas.spec.ts @@ -59,7 +59,11 @@ describe("recorded MCP tool schemas", () => { // 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"]) { + for (const name of [ + "asset-mcp-tools.json", + "sculpt-mcp-tools.json", + "animation-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"), diff --git a/scripts/__tests__/package-externals.spec.ts b/scripts/__tests__/package-externals.spec.ts index 75f3548246..b943481e60 100644 --- a/scripts/__tests__/package-externals.spec.ts +++ b/scripts/__tests__/package-externals.spec.ts @@ -152,6 +152,16 @@ const INLINED_BY_DESIGN: Readonly> = { ], }; +/** + * Stdio tool processes whose built output is self-contained on purpose. A game never imports + * them, so no second copy of three.js or React can meet the game's own instance. The bundle + * must carry its runtimes: core commits a copy of the server under `packages/core/mcp/`, where a + * bare `import "three"` cannot resolve, and the calibrator page loads `dist/client/app.js` in a + * browser with no module graph. Each row must keep a `bin`, which is what makes it a process + * rather than a library. + */ +const SELF_CONTAINED_TOOLS: ReadonlySet = new Set(["threenative-animation-mcp"]); + // A package that ships TypeScript source (three-native) has no built output to inspect. function shipsBuiltOutput(directory: string): boolean { const manifest = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8")) as { @@ -238,6 +248,13 @@ describe("published packages keep their runtime dependencies external", () => { for (const { directory, imports, name } of packagesUnderTest().filter((entry) => shipsBuiltOutput(entry.directory), )) { + if (SELF_CONTAINED_TOOLS.has(name)) { + const manifest = JSON.parse( + fs.readFileSync(path.join(directory, "package.json"), "utf8"), + ) as { bin?: unknown }; + if (manifest.bin === undefined) offenders.push(`${name} is excused but has no bin`); + continue; + } const needed = [...new Set(imports.map(packageRootOf))].filter((root) => SHARED_RUNTIMES.includes(root), ); diff --git a/scripts/__tests__/release.spec.ts b/scripts/__tests__/release.spec.ts index eb27a7d2f1..3b69db6c9e 100644 --- a/scripts/__tests__/release.spec.ts +++ b/scripts/__tests__/release.spec.ts @@ -119,6 +119,7 @@ describe("pnpm release ordering", () => { "threenative-sculpt", "threenative-engine", "threenative-blender", + "threenative-animation", ]); expect(cohort.templatePins).toEqual([]); expect(cohort.order).toContain("create-threenative"); diff --git a/scripts/__tests__/verify-registry-install.spec.ts b/scripts/__tests__/verify-registry-install.spec.ts index 68d43d111a..36dd21fd68 100644 --- a/scripts/__tests__/verify-registry-install.spec.ts +++ b/scripts/__tests__/verify-registry-install.spec.ts @@ -160,6 +160,7 @@ function happyRunner(): CommandRunner { path.join(project, ".mcp.json"), JSON.stringify({ mcpServers: { + "threenative-animation": { command: "node", args: ["animation.mjs"] }, "threenative-assets": { command: "node", args: ["assets.mjs"] }, "threenative-blender": { command: "node", args: ["blender.mjs"] }, "threenative-engine": { command: "node", args: ["engine.mjs"] }, @@ -319,6 +320,7 @@ function happyMcpRunner(): McpRunner { const operation = parsed.find((request) => request.method === "tools/call"); const toolName = (operation?.params?.name as string | undefined) ?? ""; const tools: Record = { + "threenative-animation": ["animation_inspect"], "threenative-assets": ["asset_search_sources"], "threenative-blender": ["blender_status", "blender_convert"], "threenative-engine": ["engine_search_capabilities", "engine_capability_detail"], @@ -327,62 +329,64 @@ function happyMcpRunner(): McpRunner { const result = toolName === "asset_search_sources" ? { content: [{ text: JSON.stringify({ sources: [{ id: "kenney" }], total: 1 }) }] } - : toolName === "sculpt_grimoire" - ? { - content: [ - { - text: JSON.stringify({ - text: "Use silhouette and readable geometry.", - topic: "glossary/3d_vocabulary", - }), - }, - ], - } - : toolName === "engine_search_capabilities" + : toolName === "animation_inspect" + ? { content: [{ text: JSON.stringify({ bones: [], clips: [] }) }] } + : toolName === "sculpt_grimoire" ? { content: [ { text: JSON.stringify({ - guidance: "", - results: [ - { - constraints: ["requires a navigation world"], - example: "const agent = new NavigationAgent3D(world);", - importPath: "@threenative/physics/navigation", - summary: "Move an agent around obstacles.", - symbol: "NavigationAgent3D", - }, - ], + text: "Use silhouette and readable geometry.", + topic: "glossary/3d_vocabulary", }), }, ], } - : toolName === "engine_capability_detail" + : toolName === "engine_search_capabilities" ? { content: [ { text: JSON.stringify({ - importPath: "@threenative/physics/navigation", - symbol: "NavigationAgent3D", + guidance: "", + results: [ + { + constraints: ["requires a navigation world"], + example: "const agent = new NavigationAgent3D(world);", + importPath: "@threenative/physics/navigation", + summary: "Move an agent around obstacles.", + symbol: "NavigationAgent3D", + }, + ], }), }, ], } - : toolName === "blender_status" + : toolName === "engine_capability_detail" ? { content: [ { text: JSON.stringify({ - available: true, - detail: "Blender 4.2", - installCommand: "install Blender", + importPath: "@threenative/physics/navigation", + symbol: "NavigationAgent3D", }), }, ], } - : { - content: [{ text: JSON.stringify({ ok: true }) }], - }; + : toolName === "blender_status" + ? { + content: [ + { + text: JSON.stringify({ + available: true, + detail: "Blender 4.2", + installCommand: "install Blender", + }), + }, + ], + } + : { + content: [{ text: JSON.stringify({ ok: true }) }], + }; const output = [ mcpMessage(1, { serverInfo: { name: serverName } }), mcpMessage(2, { tools: (tools[serverName] ?? []).map((name) => ({ name })) }), diff --git a/scripts/capture-blender-mcp-tools.ts b/scripts/capture-blender-mcp-tools.ts index 51c969ea2f..db4da07b00 100644 --- a/scripts/capture-blender-mcp-tools.ts +++ b/scripts/capture-blender-mcp-tools.ts @@ -7,7 +7,8 @@ // The published-tarball form PRD-346 asks for is not available while the package is unpublished, so // this packs the workspace and installs *that artifact*, which is what CI scaffolds from too. // -// pnpm tsx scripts/capture-blender-mcp-tools.ts +// pnpm tsx scripts/capture-blender-mcp-tools.ts # blender (default) +// pnpm tsx scripts/capture-blender-mcp-tools.ts animation # the same capture for the animation server import { type ChildProcessWithoutNullStreams, execFileSync, spawn } from "node:child_process"; import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,15 +16,39 @@ 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/blender-mcp-tools.json"); -const PACKAGE = "threenative-blender-mcp"; -const RECOMMENDED = [ - "blender_status", - "blender_inspect", - "blender_convert", - "blender_recipes", - "blender_run_python", -] as const; +/** One row per server this capture covers. `statusTool` is the tool whose reply shape is recorded; + * a server without one records no `statusShape`. */ +const SERVERS = { + animation: { + label: "animation", + package: "threenative-animation-mcp", + recommended: ["animation_inspect", "animation_attach", "animation_open_view"], + snapshot: "animation-mcp-tools.json", + statusTool: undefined, + }, + blender: { + label: "blender", + package: "threenative-blender-mcp", + recommended: [ + "blender_status", + "blender_inspect", + "blender_convert", + "blender_recipes", + "blender_run_python", + ], + snapshot: "blender-mcp-tools.json", + statusTool: "blender_status", + }, +} as const; +const SELECTED = SERVERS[(process.argv[2] ?? "blender") as keyof typeof SERVERS]; +if (SELECTED === undefined) { + throw new Error( + `TN_MCP_SNAPSHOT: unknown server '${process.argv[2]}'; use blender or animation.`, + ); +} +const SNAPSHOT = path.join(REPO, "packages/create-threenative", SELECTED.snapshot); +const PACKAGE = SELECTED.package; +const RECOMMENDED = SELECTED.recommended; /** Scratch roots this run owns, removed together in `main`'s finally. */ const scratch: string[] = []; @@ -99,10 +124,13 @@ async function main(): Promise { }); child.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n'); const listed = await request(child, lines, next, "tools/list"); - const status = await request(child, lines, next, "tools/call", { - arguments: {}, - name: "blender_status", - }); + const status = + SELECTED.statusTool === undefined + ? undefined + : await request(child, lines, next, "tools/call", { + arguments: {}, + name: SELECTED.statusTool, + }); // 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; @@ -116,19 +144,21 @@ async function main(): Promise { const descriptions = Object.fromEntries( listedTools.map((tool) => [tool.name, tool.description ?? ""]), ); - const statusText = (status.content as { text: string }[])[0]?.text ?? "{}"; + const statusText = (status?.content as { text: string }[] | undefined)?.[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\`.`, + comment: `The ${SELECTED.label} 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${SELECTED.label === "blender" ? "" : ` ${SELECTED.label}`}\`.`, version: manifest.version, serverInfo: initialized.serverInfo, recommended: [...RECOMMENDED], tools, inputSchemas, descriptions, - statusShape: Object.keys(JSON.parse(statusText) as Record).sort(), + ...(status === undefined + ? {} + : { statusShape: Object.keys(JSON.parse(statusText) as Record).sort() }), }; writeFileSync(SNAPSHOT, `${JSON.stringify(snapshot, null, 2)}\n`); - process.stdout.write(`blender MCP surface: ${tools.length} tool(s) -> ${SNAPSHOT}\n`); + process.stdout.write(`${SELECTED.label} MCP surface: ${tools.length} tool(s) -> ${SNAPSHOT}\n`); } finally { child.kill(); lines.close(); diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index b6d0fa574c..59bad2575e 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -329,6 +329,11 @@ async function main(): Promise { run("pnpm", ["build"], "pnpm build"); run("pnpm", ["tsx", "scripts/capture-blender-mcp-tools.ts"], "capture Blender MCP surface"); + run( + "pnpm", + ["tsx", "scripts/capture-blender-mcp-tools.ts", "animation"], + "capture animation MCP surface", + ); const report = await checkPublishState({ allowCurrentPublishSetPins: true, diff --git a/scripts/verify-golden-path.ts b/scripts/verify-golden-path.ts index f771a8fd35..b6ce9e2e77 100644 --- a/scripts/verify-golden-path.ts +++ b/scripts/verify-golden-path.ts @@ -39,6 +39,7 @@ export const MCP_SURFACES = [ { name: "threenative-sculpt", file: "sculpt-mcp-tools.json" }, { name: "threenative-engine", file: "engine-mcp-tools.json" }, { name: "threenative-blender", file: "blender-mcp-tools.json" }, + { name: "threenative-animation", file: "animation-mcp-tools.json" }, ] as const; const AUTHORING_CAPABILITIES = [ diff --git a/scripts/verify-registry-install.ts b/scripts/verify-registry-install.ts index e04f3d128d..d5e985583d 100644 --- a/scripts/verify-registry-install.ts +++ b/scripts/verify-registry-install.ts @@ -244,11 +244,13 @@ export function realRunner(env: NodeJS.ProcessEnv): CommandRunner { } interface IMcpFixturePaths { + readonly glb?: string; readonly source?: string; readonly out?: string; } const MCP_SAFE_TOOLS: Readonly> = { + "threenative-animation": "animation_inspect", "threenative-assets": "asset_search_sources", "threenative-blender": "blender_status", "threenative-engine": "engine_search_capabilities", @@ -267,11 +269,13 @@ function safeOperation( const argumentsValue: Record = name === "asset_search_sources" ? { category: "all", query: "game" } - : name === "sculpt_grimoire" - ? { topic: "glossary/3d_vocabulary" } - : name === "engine_search_capabilities" - ? { scope: "mechanic", situation: "enemy walks around a wall" } - : {}; + : name === "animation_inspect" + ? { file: fixture.glb } + : name === "sculpt_grimoire" + ? { topic: "glossary/3d_vocabulary" } + : name === "engine_search_capabilities" + ? { scope: "mechanic", situation: "enemy walks around a wall" } + : {}; return { arguments: argumentsValue, name }; } @@ -1070,6 +1074,20 @@ function objectRecord(value: unknown): Record | undefined { : undefined; } +/** An empty glTF scene as a binary GLB: the smallest file `animation_inspect` can read, and one + * this script owns, so the check never depends on a model a game happens to ship. */ +function emptyGlb(): Buffer { + const json = Buffer.from('{"asset":{"version":"2.0"},"scene":0,"scenes":[{}]}'); + const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]); + const header = Buffer.alloc(20); + header.write("glTF", 0, "ascii"); + header.writeUInt32LE(2, 4); + header.writeUInt32LE(20 + padded.length, 8); + header.writeUInt32LE(padded.length, 12); + header.write("JSON", 16, "ascii"); + return Buffer.concat([header, padded]); +} + function mcpStep(project: string, runner: McpRunner): string { const configPath = path.join(project, ".mcp.json"); if (!fs.existsSync(configPath)) throw new Error(`MCP configuration is missing: ${configPath}.`); @@ -1107,7 +1125,13 @@ function mcpStep(project: string, runner: McpRunner): string { out: path.join(project, ".registry-mcp", "triangle.glb"), source: path.join(project, ".registry-mcp", "triangle.obj"), } - : {}; + : name === "threenative-animation" + ? { glb: path.join(project, ".registry-mcp", "empty.glb") } + : {}; + if (fixture.glb !== undefined) { + fs.mkdirSync(path.dirname(fixture.glb), { recursive: true }); + fs.writeFileSync(fixture.glb, emptyGlb()); + } if (fixture.source !== undefined && fixture.out !== undefined) { fs.mkdirSync(path.dirname(fixture.source), { recursive: true }); fs.writeFileSync( @@ -1139,6 +1163,10 @@ function mcpStep(project: string, runner: McpRunner): string { "asset_search_sources returned no source directory payload with sources and total.", ); results.push(`${name}: initialize, tools/list and asset_search_sources ok`); + } else if (name === "threenative-animation") { + if (record === undefined || !Array.isArray(record.bones) || !Array.isArray(record.clips)) + throw new Error("animation_inspect returned no report with bones and clips."); + results.push(`${name}: initialize, tools/list and animation_inspect ok`); } else if (name === "threenative-sculpt") { if ( record === undefined ||