Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/animation-mcp/src/ops/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof migrateRecipe>[0]));
throw new Error(`Unsupported animation log version '${String(value.version)}'.`);
}
Expand Down
167 changes: 167 additions & 0 deletions packages/create-threenative/animation-mcp-tools.json
Original file line number Diff line number Diff line change
@@ -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 (<model>.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."
}
}
1 change: 1 addition & 0 deletions packages/create-threenative/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ const CORE_PACKAGE_VERSION = "0.3.6";
const CORE_RESOLVED_SERVERS: ReadonlySet<string> = new Set([
"threenative-engine",
"threenative-blender",
"threenative-animation",
]);

export const MCP_SERVER_SPECS: readonly IMcpServerSpec[] = Object.entries(MCP_SERVERS).map(
Expand Down
17 changes: 17 additions & 0 deletions packages/runtime-native/__tests__/profile-production.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
6 changes: 3 additions & 3 deletions packages/runtime-native/scripts/profile-production.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down
6 changes: 5 additions & 1 deletion scripts/__tests__/mcp-tool-schemas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
17 changes: 17 additions & 0 deletions scripts/__tests__/package-externals.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ const INLINED_BY_DESIGN: Readonly<Record<string, readonly string[]>> = {
],
};

/**
* 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<string> = 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 {
Expand Down Expand Up @@ -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),
);
Expand Down
1 change: 1 addition & 0 deletions scripts/__tests__/release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
68 changes: 36 additions & 32 deletions scripts/__tests__/verify-registry-install.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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<string, string[]> = {
"threenative-animation": ["animation_inspect"],
"threenative-assets": ["asset_search_sources"],
"threenative-blender": ["blender_status", "blender_convert"],
"threenative-engine": ["engine_search_capabilities", "engine_capability_detail"],
Expand All @@ -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 })) }),
Expand Down
Loading
Loading