diff --git a/.gitignore b/.gitignore index a3e37261..7c4aee78 100644 --- a/.gitignore +++ b/.gitignore @@ -6,22 +6,36 @@ node_modules .pnp.js # testing -coverage +coverage/ +.nyc_output/ +test-results/ +playwright-report/ +blob-report/ +junit*.xml # next.js .next/ out/ build +# build / compile artifacts +dist/ +*.tsbuildinfo +tsconfig.tsbuildinfo +!apps/mcp-server/dist/ +!apps/mcp-server/dist/** + # misc .DS_Store *.pem +*.lock # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +*.log # local env files .env.local diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md new file mode 100644 index 00000000..67af344f --- /dev/null +++ b/apps/mcp-server/README.md @@ -0,0 +1,71 @@ +# DumbCode Studio MCP Server + +This package exposes DumbCode Studio as a local Model Context Protocol server. It forwards MCP tool calls to the Studio REST bridge under `/api/mcp/*`; the open Studio browser tab polls that bridge and executes commands inside the real React/Three Studio state. + +## Run + +From the repository root: + +```powershell +yarn install +yarn workspace dumbcode-studio-mcp build +$env:DCS_STUDIO_URL="http://localhost:3000"; yarn workspace dumbcode-studio-mcp start +``` + +Start Studio separately and keep it open in a browser: + +```powershell +yarn workspace studio dev +``` + +Then visit `http://localhost:3000`. The browser tab registers itself with `/api/mcp/health`. + +## Optional bearer token + +To require a token on external MCP-to-Studio REST calls, start both the MCP server and Studio with the same environment value: + +```powershell +$env:DCS_MCP_TOKEN="your-long-random-token"; $env:DCS_STUDIO_URL="http://localhost:3000"; yarn workspace dumbcode-studio-mcp start +``` + +## MCP host configuration + +Example stdio configuration: + +```json +{ + "mcpServers": { + "dumbcode-studio": { + "command": "node", + "args": ["/absolute/path/to/DumbCode-Studio/apps/mcp-server/dist/index.js"], + "env": { + "DCS_STUDIO_URL": "http://localhost:3000" + } + } + } +} +``` + +With bearer authentication enabled: + +```json +{ + "mcpServers": { + "dumbcode-studio": { + "command": "node", + "args": ["/absolute/path/to/DumbCode-Studio/apps/mcp-server/dist/index.js"], + "env": { + "DCS_STUDIO_URL": "http://localhost:3000", + "DCS_MCP_TOKEN": "your-long-random-token" + } + } + } +} +``` + +## Main tools + +- `dcs_status` checks whether Studio is reachable and whether a browser bridge is connected. +- `dcs_get_action_schema` returns every available Studio action and argument contract. +- `dcs_action` executes any action by name. +- `dcs_` tools expose each action directly, for example `dcs_create_cube`, `dcs_create_animation`, `dcs_set_keyframe_transform`, and `dcs_export_asset`. diff --git a/apps/mcp-server/dist/index.d.ts b/apps/mcp-server/dist/index.d.ts new file mode 100644 index 00000000..b7988016 --- /dev/null +++ b/apps/mcp-server/dist/index.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/apps/mcp-server/dist/index.js b/apps/mcp-server/dist/index.js new file mode 100644 index 00000000..1791a974 --- /dev/null +++ b/apps/mcp-server/dist/index.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { MCP_ACTION_DESCRIPTIONS, MCP_ACTION_SCHEMA, MCP_ACTIONS } from "../../../packages/mcp-contract/index.js"; +const studioBaseUrl = (process.env.DCS_STUDIO_URL ?? "http://localhost:3000").replace(/\/$/, ""); +const bearerToken = process.env.DCS_MCP_TOKEN; +const asUrl = (path) => new URL(path, `${studioBaseUrl}/`).toString(); +const requestStudio = async (path, init = {}) => { + const headers = new Headers(init.headers); + if (bearerToken) + headers.set("Authorization", `Bearer ${bearerToken}`); + if (init.body !== undefined && !headers.has("Content-Type")) + headers.set("Content-Type", "application/json"); + const response = await fetch(asUrl(path), { ...init, headers }); + const text = await response.text(); + let body = text; + if (text) { + try { + body = JSON.parse(text); + } + catch { + body = text; + } + } + if (!response.ok) { + const message = typeof body === "object" && body !== null && "error" in body ? String(body.error) : text; + throw new Error(`DumbCode Studio REST call failed (${response.status} ${response.statusText}): ${message}`); + } + return body; +}; +const executeStudioAction = async (action, args = {}, timeoutMs) => { + const body = timeoutMs === undefined ? args : { ...args, timeoutMs }; + const response = await requestStudio(`/api/mcp/action/${encodeURIComponent(action)}`, { + method: "POST", + body: JSON.stringify(body), + }); + if (typeof response === "object" && response !== null && "result" in response) { + return response.result; + } + return response; +}; +const toolText = (value) => ({ + content: [{ type: "text", text: value === undefined ? "undefined" : (typeof value === "string" ? value : JSON.stringify(value, null, 2)) }], +}); +const timeoutSchema = z.number().int().positive().max(600000).optional().describe("Optional action timeout in milliseconds"); +const argsSchema = z.record(z.unknown()).optional().describe("Backward-compatible action-specific arguments object"); +const directActionInputSchema = (action) => { + const shape = { + args: argsSchema, + timeoutMs: timeoutSchema, + }; + for (const [name, description] of Object.entries(MCP_ACTION_SCHEMA[action])) { + shape[name] = z.unknown().optional().describe(description); + } + return z.object(shape).passthrough(); +}; +const normalizeActionArgs = (input) => { + const { args, timeoutMs: _timeoutMs, ...directArgs } = input; + const baseArgs = args !== undefined && typeof args === "object" && args !== null && !Array.isArray(args) + ? args + : {}; + return { ...baseArgs, ...directArgs }; +}; +const server = new McpServer({ + name: "dumbcode-studio", + version: "1.0.0", +}); +server.registerTool("dcs_status", { + description: "Check the DumbCode Studio REST bridge, connected browser clients, queue depth, and available actions.", + inputSchema: {}, +}, async () => toolText(await requestStudio("/api/mcp/health"))); +server.registerTool("dcs_action", { + description: "Run any DumbCode Studio action. Call dcs_get_action_schema first when choosing argument names.", + inputSchema: { + action: z.enum([...MCP_ACTIONS]).describe("DumbCode Studio action name"), + args: z.record(z.unknown()).optional().describe("Action-specific arguments. See dcs_get_action_schema."), + timeoutMs: timeoutSchema, + }, +}, async ({ action, args, timeoutMs }) => toolText(await executeStudioAction(action, args ?? {}, timeoutMs))); +for (const action of MCP_ACTIONS) { + server.registerTool(`dcs_${action}`, { + description: `${MCP_ACTION_DESCRIPTIONS[action]} Pass fields directly, or pass { args: ... } for compatibility.`, + inputSchema: directActionInputSchema(action), + }, async (input) => toolText(await executeStudioAction(action, normalizeActionArgs(input), input.timeoutMs))); +} +const main = async () => { + const transport = new StdioServerTransport(); + await server.connect(transport); +}; +main().catch(error => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + console.error(message); + process.exit(1); +}); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/apps/mcp-server/dist/index.js.map b/apps/mcp-server/dist/index.js.map new file mode 100644 index 00000000..d1600acc --- /dev/null +++ b/apps/mcp-server/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAA;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,EAAE,uBAAuB,EAAE,iBAAiB,EAAE,WAAW,EAAsB,MAAM,yCAAyC,CAAA;AAErI,MAAM,aAAa,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,uBAAuB,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AAChG,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAA;AAE7C,MAAM,KAAK,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,IAAI,EAAE,GAAG,aAAa,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAA;AAE7E,MAAM,aAAa,GAAG,KAAK,EAAE,IAAY,EAAE,OAAoB,EAAE,EAAE,EAAE;IACnE,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IACzC,IAAI,WAAW;QAAE,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,WAAW,EAAE,CAAC,CAAA;IACtE,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;QAAE,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAA;IAE5G,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,CAAC,CAAA;IAC/D,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAClC,IAAI,IAAI,GAAY,IAAI,CAAA;IACxB,IAAI,IAAI,EAAE,CAAC;QACT,IAAI,CAAC;YACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACzB,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,GAAG,IAAI,CAAA;QACb,CAAC;IACH,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,OAAO,GAAG,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAE,IAA2B,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QAChI,MAAM,IAAI,KAAK,CAAC,qCAAqC,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,UAAU,MAAM,OAAO,EAAE,CAAC,CAAA;IAC7G,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC,CAAA;AAED,MAAM,mBAAmB,GAAG,KAAK,EAAE,MAAqB,EAAE,OAAgC,EAAE,EAAE,SAAkB,EAAE,EAAE;IAClH,MAAM,IAAI,GAAG,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,CAAA;IACpE,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,mBAAmB,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE;QACpF,MAAM,EAAE,MAAM;QACd,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;KAC3B,CAAC,CAAA;IACF,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,IAAI,QAAQ,EAAE,CAAC;QAC9E,OAAQ,QAAgC,CAAC,MAAM,CAAA;IACjD,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA;AAED,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAE,EAAE,CAAC,CAAC;IACpC,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;CACrJ,CAAC,CAAA;AAEF,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yCAAyC,CAAC,CAAA;AAC5H,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC,CAAA;AAEpH,MAAM,uBAAuB,GAAG,CAAC,MAAqB,EAAE,EAAE;IACxD,MAAM,KAAK,GAAiC;QAC1C,IAAI,EAAE,UAAU;QAChB,SAAS,EAAE,aAAa;KACzB,CAAA;IACD,KAAK,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QAC5E,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;IAC5D,CAAC;IACD,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;AACtC,CAAC,CAAA;AAED,MAAM,mBAAmB,GAAG,CAAC,KAA8B,EAAE,EAAE;IAC7D,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,GAAG,UAAU,EAAE,GAAG,KAAK,CAAA;IAC5D,MAAM,QAAQ,GAAG,IAAI,KAAK,SAAS,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QACtG,CAAC,CAAC,IAA+B;QACjC,CAAC,CAAC,EAAE,CAAA;IACN,OAAO,EAAE,GAAG,QAAQ,EAAE,GAAG,UAAU,EAAE,CAAA;AACvC,CAAC,CAAA;AAED,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,iBAAiB;IACvB,OAAO,EAAE,OAAO;CACjB,CAAC,CAAA;AAEF,MAAM,CAAC,YAAY,CACjB,YAAY,EACZ;IACE,WAAW,EAAE,uGAAuG;IACpH,WAAW,EAAE,EAAE;CAChB,EACD,KAAK,IAAI,EAAE,CAAC,QAAQ,CAAC,MAAM,aAAa,CAAC,iBAAiB,CAAC,CAAC,CAC7D,CAAA;AAED,MAAM,CAAC,YAAY,CACjB,YAAY,EACZ;IACE,WAAW,EAAE,gGAAgG;IAC7G,WAAW,EAAE;QACb,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,WAAW,CAAwC,CAAC,CAAC,QAAQ,CAAC,6BAA6B,CAAC;QAC/G,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,uDAAuD,CAAC;QACxG,SAAS,EAAE,aAAa;KACvB;CACF,EACD,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,mBAAmB,CAAC,MAAM,EAAE,IAAI,IAAI,EAAE,EAAE,SAAS,CAAC,CAAC,CAC1G,CAAA;AAED,KAAK,MAAM,MAAM,IAAI,WAAW,EAAE,CAAC;IACjC,MAAM,CAAC,YAAY,CACjB,OAAO,MAAM,EAAE,EACf;QACE,WAAW,EAAE,GAAG,uBAAuB,CAAC,MAAM,CAAC,iEAAiE;QAChH,WAAW,EAAE,uBAAuB,CAAC,MAAM,CAAC;KAC7C,EACD,KAAK,EAAC,KAAK,EAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,mBAAmB,CAAC,MAAM,EAAE,mBAAmB,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,SAA+B,CAAC,CAAC,CAC9H,CAAA;AACH,CAAC;AAED,MAAM,IAAI,GAAG,KAAK,IAAI,EAAE;IACtB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAA;IAC5C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;AACjC,CAAC,CAAA;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;IACnB,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IACrF,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;IACtB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json new file mode 100644 index 00000000..9c14ec0c --- /dev/null +++ b/apps/mcp-server/package.json @@ -0,0 +1,26 @@ +{ + "name": "dumbcode-studio-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "bin": { + "dumbcode-studio-mcp": "dist/index.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx src/index.ts", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^20.12.12", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18.18.0" + } +} diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts new file mode 100644 index 00000000..d2a7791b --- /dev/null +++ b/apps/mcp-server/src/index.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +import { MCP_ACTION_DESCRIPTIONS, MCP_ACTION_SCHEMA, MCP_ACTIONS, type McpActionName } from "../../../packages/mcp-contract/index.js" + +const studioBaseUrl = (process.env.DCS_STUDIO_URL ?? "http://localhost:3000").replace(/\/$/, "") +const bearerToken = process.env.DCS_MCP_TOKEN + +const asUrl = (path: string) => new URL(path, `${studioBaseUrl}/`).toString() + +const requestStudio = async (path: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers) + if (bearerToken) headers.set("Authorization", `Bearer ${bearerToken}`) + if (init.body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json") + + const response = await fetch(asUrl(path), { ...init, headers }) + const text = await response.text() + let body: unknown = text + if (text) { + try { + body = JSON.parse(text) + } catch { + body = text + } + } + + if (!response.ok) { + const message = typeof body === "object" && body !== null && "error" in body ? String((body as { error: unknown }).error) : text + throw new Error(`DumbCode Studio REST call failed (${response.status} ${response.statusText}): ${message}`) + } + + return body +} + +const executeStudioAction = async (action: McpActionName, args: Record = {}, timeoutMs?: number) => { + const body = timeoutMs === undefined ? args : { ...args, timeoutMs } + const response = await requestStudio(`/api/mcp/action/${encodeURIComponent(action)}`, { + method: "POST", + body: JSON.stringify(body), + }) + if (typeof response === "object" && response !== null && "result" in response) { + return (response as { result: unknown }).result + } + return response +} + +const toolText = (value: unknown) => ({ + content: [{ type: "text" as const, text: value === undefined ? "undefined" : (typeof value === "string" ? value : JSON.stringify(value, null, 2)) }], +}) + +const timeoutSchema = z.number().int().positive().max(600000).optional().describe("Optional action timeout in milliseconds") +const argsSchema = z.record(z.unknown()).optional().describe("Backward-compatible action-specific arguments object") + +const directActionInputSchema = (action: McpActionName) => { + const shape: Record = { + args: argsSchema, + timeoutMs: timeoutSchema, + } + for (const [name, description] of Object.entries(MCP_ACTION_SCHEMA[action])) { + shape[name] = z.unknown().optional().describe(description) + } + return z.object(shape).passthrough() +} + +const normalizeActionArgs = (input: Record) => { + const { args, timeoutMs: _timeoutMs, ...directArgs } = input + const baseArgs = args !== undefined && typeof args === "object" && args !== null && !Array.isArray(args) + ? args as Record + : {} + return { ...baseArgs, ...directArgs } +} + +const server = new McpServer({ + name: "dumbcode-studio", + version: "1.0.0", +}) + +server.registerTool( + "dcs_status", + { + description: "Check the DumbCode Studio REST bridge, connected browser clients, queue depth, and available actions.", + inputSchema: {}, + }, + async () => toolText(await requestStudio("/api/mcp/health")) +) + +server.registerTool( + "dcs_action", + { + description: "Run any DumbCode Studio action. Call dcs_get_action_schema first when choosing argument names.", + inputSchema: { + action: z.enum([...MCP_ACTIONS] as [McpActionName, ...McpActionName[]]).describe("DumbCode Studio action name"), + args: z.record(z.unknown()).optional().describe("Action-specific arguments. See dcs_get_action_schema."), + timeoutMs: timeoutSchema, + }, + }, + async ({ action, args, timeoutMs }) => toolText(await executeStudioAction(action, args ?? {}, timeoutMs)) +) + +for (const action of MCP_ACTIONS) { + server.registerTool( + `dcs_${action}`, + { + description: `${MCP_ACTION_DESCRIPTIONS[action]} Pass fields directly, or pass { args: ... } for compatibility.`, + inputSchema: directActionInputSchema(action), + }, + async input => toolText(await executeStudioAction(action, normalizeActionArgs(input), input.timeoutMs as number | undefined)) + ) +} + +const main = async () => { + const transport = new StdioServerTransport() + await server.connect(transport) +} + +main().catch(error => { + const message = error instanceof Error ? error.stack ?? error.message : String(error) + console.error(message) + process.exit(1) +}) diff --git a/apps/mcp-server/tsconfig.json b/apps/mcp-server/tsconfig.json new file mode 100644 index 00000000..f10609ce --- /dev/null +++ b/apps/mcp-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/studio/pages/api/mcp/_state.ts b/apps/studio/pages/api/mcp/_state.ts new file mode 100644 index 00000000..395f394e --- /dev/null +++ b/apps/studio/pages/api/mcp/_state.ts @@ -0,0 +1,280 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { randomUUID } from "crypto" +import { MCP_ACTIONS, type McpActionName, type McpArgs, type McpCommandResult, type McpQueuedCommand } from "../../../src/studio/mcp/types" + +type QueuedCommand = McpQueuedCommand & { + targetClientId: string + leasedAt?: number + leaseExpiresAt?: number +} + +type PendingCommand = QueuedCommand & { + resolve: (value: unknown) => void + reject: (reason?: unknown) => void + timer: NodeJS.Timeout + timeoutAt: number +} + +type ClientState = { + id: string + secret: string + firstSeenAt: number + lastSeenAt: number + lastFocusedAt: number + visible: boolean + focused: boolean + userAgent?: string +} + +type McpApiState = { + queue: QueuedCommand[] + pending: Map + clients: Map +} + +const globalKey = "__dumbcode_studio_mcp_api_state__" + +const state = (() => { + const g = globalThis as typeof globalThis & { [globalKey]?: McpApiState } + if (!g[globalKey]) { + g[globalKey] = { + queue: [], + pending: new Map(), + clients: new Map(), + } + } + return g[globalKey]! +})() + +const DEFAULT_TIMEOUT_MS = 30_000 +const MAX_TIMEOUT_MS = 10 * 60_000 +const MAX_QUEUE_LENGTH = 500 +const CLIENT_STALE_MS = 20_000 +const COMMAND_LEASE_MS = 10_000 + +const isMcpActionName = (value: unknown): value is McpActionName => + typeof value === "string" && (MCP_ACTIONS as readonly string[]).includes(value) + +export class McpHttpError extends Error { + readonly statusCode: number + constructor(statusCode: number, message: string) { + super(message) + this.statusCode = statusCode + } +} + +export const json = (res: NextApiResponse, status: number, body: unknown) => { + res.setHeader("Cache-Control", "no-store") + res.status(status).json(body) +} + +export const methodGuard = (req: NextApiRequest, res: NextApiResponse, methods: readonly string[]) => { + if (!methods.includes(req.method ?? "")) { + res.setHeader("Allow", methods.join(", ")) + json(res, 405, { error: `Method ${req.method} is not allowed. Use ${methods.join(", ")}.` }) + return false + } + return true +} + +export const requireAgentAuthorization = (req: NextApiRequest) => { + const expected = process.env.DCS_MCP_TOKEN + if (!expected) { + return + } + + const header = req.headers.authorization ?? "" + const token = Array.isArray(header) ? header[0] : header + if (token !== `Bearer ${expected}`) { + throw new McpHttpError(401, "Invalid or missing DCS_MCP_TOKEN bearer token") + } +} + +const publicClient = ({ secret: _secret, ...client }: ClientState) => client + +const firstHeader = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value + +export const readClientCredentials = (req: NextApiRequest) => { + const clientId = firstHeader(req.headers["x-dcs-mcp-client-id"]) ?? (typeof req.query.clientId === "string" ? req.query.clientId : undefined) + const secret = firstHeader(req.headers["x-dcs-mcp-client-secret"]) ?? (typeof req.query.secret === "string" ? req.query.secret : undefined) + if (!clientId || !secret) { + throw new McpHttpError(401, "MCP browser client credentials are required") + } + return { clientId, secret } +} + +export const assertClientCredentials = (clientId: string, secret: string) => { + const client = state.clients.get(clientId) + if (!client || client.secret !== secret) { + throw new McpHttpError(401, "Invalid MCP browser client credentials") + } + return client +} + +export const pruneClients = () => { + const cutoff = Date.now() - CLIENT_STALE_MS + for (const [id, client] of state.clients) { + if (client.lastSeenAt < cutoff) { + state.clients.delete(id) + } + } +} + +const pruneExpiredLeases = () => { + const current = Date.now() + state.queue.forEach(command => { + if (command.leaseExpiresAt !== undefined && command.leaseExpiresAt <= current) { + delete command.leasedAt + delete command.leaseExpiresAt + } + }) +} + +export const listClients = () => { + pruneClients() + return [...state.clients.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt).map(publicClient) +} + +const sortActiveClients = (candidates: ClientState[]) => + candidates.sort((a, b) => { + if (a.focused !== b.focused) return a.focused ? -1 : 1 + if (a.lastFocusedAt !== b.lastFocusedAt) return b.lastFocusedAt - a.lastFocusedAt + return b.lastSeenAt - a.lastSeenAt + })[0] + +const getActiveClient = () => { + pruneClients() + const visible = [...state.clients.values()].filter(client => client.visible) + if (visible.length > 0) { + return sortActiveClients(visible) + } + const any = [...state.clients.values()] + if (any.length === 0) return null + return sortActiveClients(any) +} + +export const registerClient = (id: string, secret: string, userAgent?: string, visible = true, focused = true) => { + const currentTime = Date.now() + const existing = state.clients.get(id) + if (existing && existing.secret !== secret) { + throw new McpHttpError(401, "Invalid MCP browser client secret") + } + state.clients.set(id, { + id, + secret, + firstSeenAt: existing?.firstSeenAt ?? currentTime, + lastSeenAt: currentTime, + lastFocusedAt: focused ? currentTime : existing?.lastFocusedAt ?? currentTime, + visible, + focused, + userAgent: userAgent ?? existing?.userAgent, + }) +} + +export const pollCommands = (clientId: string, secret: string, userAgent?: string, maxCommands = 10, visible = true, focused = true) => { + registerClient(clientId, secret, userAgent, visible, focused) + pruneExpiredLeases() + const active = getActiveClient() + if (!active || active.id !== clientId) { + return [] + } + + const max = Math.max(1, Math.min(25, maxCommands)) + const currentTime = Date.now() + const commands: McpQueuedCommand[] = [] + for (const command of state.queue) { + if (commands.length >= max) break + if (command.targetClientId !== clientId) continue + if (command.leaseExpiresAt !== undefined && command.leaseExpiresAt > currentTime) continue + command.leasedAt = currentTime + command.leaseExpiresAt = currentTime + COMMAND_LEASE_MS + const { targetClientId: _targetClientId, leasedAt: _leasedAt, leaseExpiresAt: _leaseExpiresAt, ...queued } = command + commands.push(queued) + } + return commands +} + +export const enqueueCommand = (action: McpActionName, args: McpArgs = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => { + pruneClients() + const target = getActiveClient() + if (!target) { + throw new McpHttpError(503, "No active DumbCode Studio browser bridge is connected. Open Studio in a visible browser tab and keep it focused.") + } + if (state.queue.length >= MAX_QUEUE_LENGTH) { + throw new McpHttpError(429, `MCP command queue is full (${MAX_QUEUE_LENGTH} pending commands)`) + } + + const requestedTimeout = Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS + const safeTimeout = Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout)) + const id = randomUUID() + const createdAt = Date.now() + const queued: QueuedCommand = { id, action, args, createdAt, targetClientId: target.id } + + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + state.pending.delete(id) + state.queue = state.queue.filter(command => command.id !== id) + reject(new McpHttpError(504, `Timed out waiting for DumbCode Studio browser bridge ${target.id} to execute ${action}. Keep the Studio tab visible and focused.`)) + }, safeTimeout) + + state.pending.set(id, { + ...queued, + resolve, + reject, + timer, + timeoutAt: createdAt + safeTimeout, + }) + }) + + state.queue.push(queued) + return { id, action, promise } +} + +export const completeCommand = (clientId: string, secret: string, result: McpCommandResult) => { + assertClientCredentials(clientId, secret) + const pending = state.pending.get(result.requestId) + if (!pending) { + throw new McpHttpError(404, `No pending MCP command with id ${result.requestId}`) + } + if (pending.targetClientId !== clientId) { + throw new McpHttpError(403, `MCP command ${result.requestId} belongs to another Studio browser bridge`) + } + + clearTimeout(pending.timer) + state.pending.delete(result.requestId) + state.queue = state.queue.filter(command => command.id !== result.requestId) + + if (result.ok) { + pending.resolve(result.result) + } else { + const error = new McpHttpError(500, result.error || "DumbCode Studio browser bridge failed without an error message") + if (result.stack) { + error.stack = result.stack + } + pending.reject(error) + } +} + +export const getQueueSummary = () => ({ + queued: state.queue.length, + pending: state.pending.size, + activeClientId: getActiveClient()?.id ?? null, + clients: listClients(), + actions: MCP_ACTIONS, +}) + +export const assertActionName = (value: unknown): McpActionName => { + if (!isMcpActionName(value)) { + throw new McpHttpError(400, `Unknown MCP action '${String(value)}'. Use get_action_schema or /api/mcp/actions.`) + } + return value +} + +export const handleApiError = (res: NextApiResponse, error: unknown) => { + if (error instanceof McpHttpError) { + json(res, error.statusCode, { error: error.message }) + return + } + const message = error instanceof Error ? error.message : String(error) + json(res, 500, { error: message }) +} diff --git a/apps/studio/pages/api/mcp/action/[action].ts b/apps/studio/pages/api/mcp/action/[action].ts new file mode 100644 index 00000000..e1957c8f --- /dev/null +++ b/apps/studio/pages/api/mcp/action/[action].ts @@ -0,0 +1,27 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { assertActionName, enqueueCommand, handleApiError, json, methodGuard, requireAgentAuthorization } from "../_state" + + +export const config = { + api: { + bodyParser: { sizeLimit: "100mb" }, + responseLimit: false, + }, +} +const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!methodGuard(req, res, ["POST"])) return + try { + requireAgentAuthorization(req) + const action = assertActionName(first(req.query.action)) + const body = req.body && typeof req.body === "object" ? req.body as Record : {} + const timeoutMs = typeof body.timeoutMs === "number" ? body.timeoutMs : undefined + const { timeoutMs: _timeoutMs, ...args } = body + const { id, promise } = enqueueCommand(action, args, timeoutMs) + const result = await promise + json(res, 200, { requestId: id, action, result }) + } catch (error) { + handleApiError(res, error) + } +} diff --git a/apps/studio/pages/api/mcp/actions.ts b/apps/studio/pages/api/mcp/actions.ts new file mode 100644 index 00000000..80041223 --- /dev/null +++ b/apps/studio/pages/api/mcp/actions.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { MCP_ACTION_DESCRIPTIONS, MCP_ACTION_SCHEMA, MCP_ACTIONS } from "../../../src/studio/mcp/types" +import { handleApiError, json, methodGuard, requireAgentAuthorization } from "./_state" + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (!methodGuard(req, res, ["GET"])) return + try { + requireAgentAuthorization(req) + json(res, 200, { + actions: MCP_ACTIONS.map(name => ({ + name, + description: MCP_ACTION_DESCRIPTIONS[name], + args: MCP_ACTION_SCHEMA[name], + endpoint: `/api/mcp/action/${name}`, + })), + }) + } catch (error) { + handleApiError(res, error) + } +} diff --git a/apps/studio/pages/api/mcp/client/poll.ts b/apps/studio/pages/api/mcp/client/poll.ts new file mode 100644 index 00000000..dc2a137e --- /dev/null +++ b/apps/studio/pages/api/mcp/client/poll.ts @@ -0,0 +1,19 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { handleApiError, json, methodGuard, pollCommands, readClientCredentials } from "../_state" + +const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value +const asBool = (value: string | undefined, fallback: boolean) => value === undefined ? fallback : value === "true" + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (!methodGuard(req, res, ["GET"])) return + try { + const { clientId, secret } = readClientCredentials(req) + const max = Number(first(req.query.maxCommands) ?? 10) + const visible = asBool(first(req.query.visible), true) + const focused = asBool(first(req.query.focused), true) + const commands = pollCommands(clientId, secret, req.headers["user-agent"], Number.isFinite(max) ? max : 10, visible, focused) + json(res, 200, { commands }) + } catch (error) { + handleApiError(res, error) + } +} diff --git a/apps/studio/pages/api/mcp/client/result.ts b/apps/studio/pages/api/mcp/client/result.ts new file mode 100644 index 00000000..41dcba66 --- /dev/null +++ b/apps/studio/pages/api/mcp/client/result.ts @@ -0,0 +1,34 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import type { McpCommandResult } from "../../../../src/studio/mcp/types" +import { completeCommand, handleApiError, json, methodGuard } from "../_state" + + +export const config = { + api: { + bodyParser: { sizeLimit: "100mb" }, + responseLimit: false, + }, +} +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (!methodGuard(req, res, ["POST"])) return + try { + const body = req.body as { clientId?: unknown, secret?: unknown, result?: McpCommandResult } + const result = body?.result + if (typeof body?.clientId !== "string" || typeof body?.secret !== "string") { + json(res, 401, { error: "MCP browser client credentials are required" }) + return + } + if (!result || typeof result.requestId !== "string" || typeof result.ok !== "boolean") { + json(res, 400, { error: "Invalid MCP command result payload" }) + return + } + if (!result.ok && typeof result.error !== "string") { + json(res, 400, { error: "Invalid MCP command failure payload" }) + return + } + completeCommand(body.clientId, body.secret, result) + json(res, 200, { ok: true }) + } catch (error) { + handleApiError(res, error) + } +} diff --git a/apps/studio/pages/api/mcp/execute.ts b/apps/studio/pages/api/mcp/execute.ts new file mode 100644 index 00000000..49813052 --- /dev/null +++ b/apps/studio/pages/api/mcp/execute.ts @@ -0,0 +1,25 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import type { McpExecuteRequest } from "../../../src/studio/mcp/types" +import { assertActionName, enqueueCommand, handleApiError, json, methodGuard, requireAgentAuthorization } from "./_state" + + +export const config = { + api: { + bodyParser: { sizeLimit: "100mb" }, + responseLimit: false, + }, +} +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!methodGuard(req, res, ["POST"])) return + try { + requireAgentAuthorization(req) + const body = req.body as Partial + const action = assertActionName(body?.action) + const args = body?.args && typeof body.args === "object" ? body.args : {} + const { id, promise } = enqueueCommand(action, args, body?.timeoutMs) + const result = await promise + json(res, 200, { requestId: id, action, result }) + } catch (error) { + handleApiError(res, error) + } +} diff --git a/apps/studio/pages/api/mcp/health.ts b/apps/studio/pages/api/mcp/health.ts new file mode 100644 index 00000000..8da526a0 --- /dev/null +++ b/apps/studio/pages/api/mcp/health.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { getQueueSummary, handleApiError, json, methodGuard, requireAgentAuthorization } from "./_state" + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (!methodGuard(req, res, ["GET"])) return + try { + requireAgentAuthorization(req) + json(res, 200, { + ok: true, + now: new Date().toISOString(), + ...getQueueSummary(), + }) + } catch (error) { + handleApiError(res, error) + } +} diff --git a/apps/studio/src/containers/StudioContainer.tsx b/apps/studio/src/containers/StudioContainer.tsx index 43077812..2f4c9d56 100644 --- a/apps/studio/src/containers/StudioContainer.tsx +++ b/apps/studio/src/containers/StudioContainer.tsx @@ -1,5 +1,5 @@ import { SVGSettings } from "@dumbcode/shared/icons"; -import { PropsWithChildren, useEffect, useRef } from "react"; +import { PropsWithChildren, useEffect } from "react"; import GithubAccountButton from "../components/GithubAccountButton"; import CreatePortalContext from "../contexts/CreatePortalContext"; import { OptionsContextProvider, useOptions } from "../contexts/OptionsContext"; @@ -10,27 +10,14 @@ import StudioPanelsContextProvider from "../contexts/StudioPanelsContext"; import ToastContext from "../contexts/ToastContext"; import TooltipContextProvider from "../contexts/TooltipContext"; import DialogBoxes from "../dialogboxes/DialogBoxes"; +import { StudioTabs } from "../studio/StudioTabs"; import { useAutoRecovery } from "../studio/autorecovery/AutoRecoveryHook"; +import McpStudioBridge from "../studio/mcp/McpStudioBridge"; import { createReadableFileExtended } from "../studio/files/FileTypes"; import { createProject, newProject } from "../studio/formats/project/DcProject"; import useNoDefaultKeypresses from "../studio/util/DisableUnwantedKeyup"; -import Animator from "../views/animator/Animator"; -import Modeler from "../views/modeler/Modeler"; +import { useWhenAction } from "../studio/util/UseWhenAction"; import Options from "../views/options/Options"; -import Project from "../views/project/Project"; -import Showcase from "../views/showcase/Showcase"; -import TextureMapper from "../views/texturemapper/Texturemapper"; -import Texturer from "../views/texturer/Texturer"; - -export const StudioTabs = [ - // { name: "options", titleComponent: () => , color: "bg-red-500", component: () => , extraClasses: "w-9 transform translate-y-1.5" }, - { name: "Project", color: "bg-purple-600 hover:bg-purple-700", component: () => }, - { name: "Modeler", color: "bg-sky-600 hover:bg-sky-700", component: () => }, - { name: "Mapper", color: "bg-teal-500 hover:bg-teal-600", component: () => }, - { name: "Texturer", color: "bg-green-500 hover:bg-green-600", component: () => }, - { name: "Animator", color: "bg-yellow-500 hover:bg-yellow-600", component: () => }, - { name: "Showcase", color: "bg-orange-500 hover:bg-orange-600", component: () => }, -] as const const StudioContainer = () => { return ( @@ -43,7 +30,10 @@ const StudioContainer = () => { - + <> + + + @@ -56,21 +46,6 @@ const StudioContainer = () => { ); }; -export const useWhenAction = (action: "create_new_model" | "last_remote_repo_project", fn: () => void) => { - const handled = useRef(false); - useEffect(() => { - if (typeof window === "undefined") { - return - } - const urlAction = new URLSearchParams(window.location.search).get("action"); - if (action === urlAction && !handled.current) { - fn() - handled.current = true - } - }, [action, fn]) -} - - const StudioApp = () => { useNoDefaultKeypresses() diff --git a/apps/studio/src/contexts/CreatePortalContext.tsx b/apps/studio/src/contexts/CreatePortalContext.tsx index a3521778..50bff289 100644 --- a/apps/studio/src/contexts/CreatePortalContext.tsx +++ b/apps/studio/src/contexts/CreatePortalContext.tsx @@ -1,8 +1,8 @@ -import { createContext, PropsWithChildren, ReactNode, ReactPortal, useContext, useRef } from "react"; +import { createContext, PropsWithChildren, ReactNode, useContext, useRef } from "react"; import { createPortal } from "react-dom"; import { useOptions } from "./OptionsContext"; -type CreatePortalContext = (children: ReactNode) => ReactPortal | null +type CreatePortalContext = (node: ReactNode) => ReturnType | null; const Context = createContext(null) diff --git a/apps/studio/src/contexts/StudioContext.tsx b/apps/studio/src/contexts/StudioContext.tsx index e6abb8cf..a7e0d7bc 100644 --- a/apps/studio/src/contexts/StudioContext.tsx +++ b/apps/studio/src/contexts/StudioContext.tsx @@ -1,5 +1,5 @@ import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; -import { StudioTabs } from '../containers/StudioContainer'; +import { StudioTabs } from '../studio/StudioTabs'; import DcProject, { newProject } from '../studio/formats/project/DcProject'; import UnsafeOperations from '../studio/util/UnsafeOperations'; import { createThreeContext, ThreeJsContext } from './ThreeContext'; diff --git a/apps/studio/src/studio/StudioTabs.tsx b/apps/studio/src/studio/StudioTabs.tsx new file mode 100644 index 00000000..42d40a45 --- /dev/null +++ b/apps/studio/src/studio/StudioTabs.tsx @@ -0,0 +1,15 @@ +import Project from "../views/project/Project" +import Modeler from "../views/modeler/Modeler" +import TextureMapper from "../views/texturemapper/Texturemapper" +import Texturer from "../views/texturer/Texturer" +import Animator from "../views/animator/Animator" +import Showcase from "../views/showcase/Showcase" + +export const StudioTabs = [ + { name: "Project", color: "bg-purple-600 hover:bg-purple-700", component: () => }, + { name: "Modeler", color: "bg-sky-600 hover:bg-sky-700", component: () => }, + { name: "Mapper", color: "bg-teal-500 hover:bg-teal-600", component: () => }, + { name: "Texturer", color: "bg-green-500 hover:bg-green-600", component: () => }, + { name: "Animator", color: "bg-yellow-500 hover:bg-yellow-600", component: () => }, + { name: "Showcase", color: "bg-orange-500 hover:bg-orange-600", component: () => }, +] as const diff --git a/apps/studio/src/studio/formats/textures/TextureManager.ts b/apps/studio/src/studio/formats/textures/TextureManager.ts index 86333e58..76fc94ec 100644 --- a/apps/studio/src/studio/formats/textures/TextureManager.ts +++ b/apps/studio/src/studio/formats/textures/TextureManager.ts @@ -259,6 +259,35 @@ export default class TextureManager { ctx.fillRect(u + ud, v + vd, uw, vh) } + static getCubeFacePixelRects(cube: DCMCube): readonly { readonly x: number; readonly y: number; readonly w: number; readonly h: number }[] { + const textureWidth = cube.model.textureWidth.value + const textureHeight = cube.model.textureHeight.value + const bounds = fitAreaWithinBounds(textureWidth, textureHeight, textureWidth, textureHeight) + const su = textureWidth / bounds.width + const sv = textureHeight / bounds.height + const textureOffset = cube.textureOffset.value + const dimension = cube.dimension.value + const u = textureOffset[0] / su + const v = textureOffset[1] / sv + const w = dimension[0] + const h = dimension[1] + const d = dimension[2] + const uw = w / su + const ud = d / su + const vh = h / sv + const vd = d / sv + const sideA = { x: u, y: v + vd, w: ud, h: vh } + const sideB = { x: u + ud + uw, y: v + vd, w: ud, h: vh } + const topA = { x: u + ud, y: v, w: uw, h: vd } + const topB = { x: u + ud + uw, y: v, w: uw, h: vd } + const bodyA = { x: u + ud + uw + ud, y: v + vd, w: uw, h: vh } + const bodyB = { x: u + ud, y: v + vd, w: uw, h: vh } + const mirrored = cube.textureMirrored.value + return mirrored + ? [sideB, sideA, topA, topB, bodyA, bodyB] + : [sideA, sideB, topA, topB, bodyA, bodyB] + } + } export class TextureGroup { @@ -462,6 +491,97 @@ export class Texture { } } +const isTextureImageReady = (el: HTMLImageElement) => el.complete && el.naturalWidth > 0 && el.naturalHeight > 0 + +const waitForTextureImage = (texture: Texture) => + new Promise((resolve, reject) => { + let current: HTMLImageElement | null = null + let finished = false + + const cleanupImage = () => { + if (current === null) return + current.removeEventListener("load", onLoad) + current.removeEventListener("error", onError) + current = null + } + const finish = (callback: () => void) => { + if (finished) return + finished = true + cleanupImage() + texture.element.removeListener(onElementChanged) + callback() + } + const onLoad = () => { + if (current !== null && isTextureImageReady(current)) { + finish(resolve) + } + } + const onError = () => finish(() => reject(new Error("Texture image failed to load"))) + const watch = (el: HTMLImageElement) => { + cleanupImage() + if (isTextureImageReady(el)) { + finish(resolve) + return + } + current = el + current.addEventListener("load", onLoad) + current.addEventListener("error", onError) + } + const onElementChanged = (el: HTMLImageElement) => watch(el) + + texture.element.addListener(onElementChanged) + watch(texture.element.value) + }) + +/** Raster-fill cube face UV regions on a bitmap texture and refresh the 3D material. */ +export const paintCubeFacesOntoRasterTexture = async ( + manager: TextureManager, + texture: Texture, + cube: DCMCube, + rgba: readonly [number, number, number, number], + faceIndexes: readonly number[] | undefined, +): Promise<{ facesPainted: number; rects: { x: number; y: number; w: number; h: number }[] }> => { + await waitForTextureImage(texture) + const el = texture.element.value + const canvas = document.createElement("canvas") + canvas.width = el.naturalWidth + canvas.height = el.naturalHeight + if (canvas.width === 0 || canvas.height === 0) { + throw new Error("Texture has zero width or height") + } + const ctx = canvas.getContext("2d") + if (ctx === null) { + throw new Error("Unable to get 2D canvas context") + } + ctx.imageSmoothingEnabled = false + ctx.drawImage(el, 0, 0) + const rects = TextureManager.getCubeFacePixelRects(cube) + const faces = faceIndexes === undefined || faceIndexes.length === 0 ? ([0, 1, 2, 3, 4, 5] as const) : faceIndexes + const alpha = rgba[3] / 255 + ctx.fillStyle = `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})` + const painted: { x: number; y: number; w: number; h: number }[] = [] + for (const i of faces) { + const r = rects[i] + if (r === undefined) { + throw new Error(`Face index ${i} is out of range (valid: 0-5)`) + } + ctx.fillRect(Math.floor(r.x), Math.floor(r.y), Math.max(1, Math.ceil(r.w)), Math.max(1, Math.ceil(r.h))) + painted.push({ x: r.x, y: r.y, w: r.w, h: r.h }) + } + await new Promise((resolve, reject) => { + const out = new Image() + out.onload = () => { + texture.element.value = out + texture.needsSaving.value = true + manager.refresh() + resolve() + } + out.onerror = () => reject(new Error("Failed to decode painted texture")) + out.src = canvas.toDataURL("image/png") + }) + return { facesPainted: faces.length, rects: painted } +} + export const useTextureDomRef = (texture: Texture, className?: string, modify?: (img: HTMLImageElement) => void) => { const [img] = useListenableObject(texture.element) const ref = useDomParent(useCallback(() => { diff --git a/apps/studio/src/studio/mcp/McpStudioBridge.tsx b/apps/studio/src/studio/mcp/McpStudioBridge.tsx new file mode 100644 index 00000000..dff24a2c --- /dev/null +++ b/apps/studio/src/studio/mcp/McpStudioBridge.tsx @@ -0,0 +1,1250 @@ +import { useEffect, useMemo, useRef } from "react" +import { StudioTabs } from "../StudioTabs" +import { useStudio, type StudioContext } from "../../contexts/StudioContext" +import { loadUnknownAnimation, writeDCAAnimationWithFormat } from "../formats/animations/DCALoader" +import DcaAnimation, { DcaKeyframe, KeyframeLayerData, type ProgressionPoint } from "../formats/animations/DcaAnimation" +import { DCMCube } from "../formats/model/DcmModel" +import { writeModelWithFormat } from "../formats/model/DCMLoader" +import { createProject, newProject, removeFileExtension } from "../formats/project/DcProject" +import { writeDcProj } from "../formats/project/DcProjectLoader" +import { paintCubeFacesOntoRasterTexture } from "../formats/textures/TextureManager" +import { createReadableFile } from "../files/FileTypes" +import { MCP_ACTION_DESCRIPTIONS, MCP_ACTION_SCHEMA, MCP_ACTIONS, type McpActionName, type McpArgs, type McpCommandResult, type McpQueuedCommand, type Vec2, type Vec3 } from "./types" +import { PAINT_FACE_DEFINITIONS, resolvePaintFaceToken } from "./paintFaceAliases" +import CubeLocker from "../util/CubeLocker" + +const POLL_INTERVAL_MS = 250 +const MAX_COMMANDS_PER_POLL = 1 +const CLIENT_ID_KEY = "dumbcode-studio:mcp-client-id" +const CLIENT_SECRET_KEY = "dumbcode-studio:mcp-client-secret" + +type JsonObject = Record +type ActionContext = { studio: StudioContext } + +const isObject = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value) +const hasKey = (object: JsonObject, key: string) => Object.prototype.hasOwnProperty.call(object, key) + +function asString(value: unknown, name: string): string +function asString(value: unknown, name: string, optional: true): string | undefined +function asString(value: unknown, name: string, optional = false) { + if (value === undefined || value === null) { + if (optional) return undefined + throw new Error(`${name} is required`) + } + if (typeof value !== "string") throw new Error(`${name} must be a string`) + return value +} + +function asNumber(value: unknown, name: string): number +function asNumber(value: unknown, name: string, optional: true): number | undefined +function asNumber(value: unknown, name: string, optional = false) { + if (value === undefined || value === null) { + if (optional) return undefined + throw new Error(`${name} is required`) + } + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`) + return value +} + +function asBoolean(value: unknown, name: string): boolean +function asBoolean(value: unknown, name: string, optional: true): boolean | undefined +function asBoolean(value: unknown, name: string, optional = false) { + if (value === undefined || value === null) { + if (optional) return undefined + throw new Error(`${name} is required`) + } + if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`) + return value +} + +function asArray(value: unknown, name: string): unknown[] +function asArray(value: unknown, name: string, optional: true): unknown[] | undefined +function asArray(value: unknown, name: string, optional = false) { + if (value === undefined || value === null) { + if (optional) return undefined + throw new Error(`${name} is required`) + } + if (!Array.isArray(value)) throw new Error(`${name} must be an array`) + return value +} + +function asVec3(value: unknown, name: string): Vec3 +function asVec3(value: unknown, name: string, optional: true): Vec3 | undefined +function asVec3(value: unknown, name: string, optional = false): Vec3 | undefined { + if (value === undefined || value === null) { + if (optional) return undefined + throw new Error(`${name} is required`) + } + const array = asArray(value, name) + if (array.length !== 3 || !array.every(item => typeof item === "number" && Number.isFinite(item))) { + throw new Error(`${name} must be a [number, number, number] tuple`) + } + return [array[0] as number, array[1] as number, array[2] as number] +} + +function asVec2(value: unknown, name: string): Vec2 +function asVec2(value: unknown, name: string, optional: true): Vec2 | undefined +function asVec2(value: unknown, name: string, optional = false): Vec2 | undefined { + if (value === undefined || value === null) { + if (optional) return undefined + throw new Error(`${name} is required`) + } + const array = asArray(value, name) + if (array.length !== 2 || !array.every(item => typeof item === "number" && Number.isFinite(item))) { + throw new Error(`${name} must be a [number, number] tuple`) + } + return [array[0] as number, array[1] as number] +} + +const asMetadata = (value: unknown, name = "metadata") => { + if (!isObject(value)) throw new Error(`${name} must be an object`) + const metadata: Record = {} + Object.entries(value).forEach(([key, entry]) => { + if (typeof entry !== "string") throw new Error(`${name}.${key} must be a string`) + metadata[key] = entry + }) + return metadata +} + +const asProgressionPoints = (value: unknown, name = "progressionPoints") => { + const array = asArray(value, name) + return array.map((entry, index) => { + if (!isObject(entry)) throw new Error(`${name}[${index}] must be an object`) + const x = asNumber(entry.x, `${name}[${index}].x`) + const y = asNumber(entry.y, `${name}[${index}].y`) + const required = asBoolean(entry.required, `${name}[${index}].required`, true) + return required === undefined ? { x, y } : { x, y, required } + }) as readonly ProgressionPoint[] +} + +const asTransformMap = (value: unknown, name: string): Map => { + if (!isObject(value)) throw new Error(`${name} must be an object keyed by cube name`) + const result = new Map() + Object.entries(value).forEach(([key, entry]) => { + const vec = asVec3(entry, `${name}.${key}`) + result.set(key, vec) + }) + return result +} + +const randomId = () => typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + +const getClientCredentials = () => { + let id = window.sessionStorage.getItem(CLIENT_ID_KEY) + if (!id) { + id = randomId() + window.sessionStorage.setItem(CLIENT_ID_KEY, id) + } + let secret = window.sessionStorage.getItem(CLIENT_SECRET_KEY) + if (!secret) { + secret = randomId() + window.sessionStorage.setItem(CLIENT_SECRET_KEY, secret) + } + return { clientId: id, secret } +} + +const cubePath = (cube: DCMCube) => { + const parts: string[] = [cube.name.value] + let parent = cube.parent + while (parent instanceof DCMCube) { + parts.unshift(parent.name.value) + parent = parent.parent + } + return parts.join("/") +} + +const serializeCube = (cube: DCMCube): JsonObject => ({ + identifier: cube.identifier, + id: cube.identifier, + name: cube.name.value, + path: cubePath(cube), + parent: cube.parent instanceof DCMCube ? cube.parent.identifier : null, + dimension: cube.dimension.value, + position: cube.position.value, + offset: cube.offset.value, + rotation: cube.rotation.value, + cubeGrow: cube.cubeGrow.value, + textureOffset: cube.textureOffset.value, + textureMirrored: cube.textureMirrored.value, + hideChildren: cube.hideChildren.value, + visible: cube.visible.value, + locked: cube.locked.value, + metadata: cube.metadata, + hierarchyLevel: cube.hierarchyLevel, + children: cube.children.value.map(serializeCube), +}) + +const serializeCubeFlat = (cube: DCMCube): JsonObject => { + const data = serializeCube(cube) + delete data.children + return data +} + +const serializeKeyframe = (keyframe: DcaKeyframe): JsonObject => ({ + identifier: keyframe.identifier, + id: keyframe.identifier, + layerId: keyframe.layerId.value, + startTime: keyframe.startTime.value, + duration: keyframe.duration.value, + selected: keyframe.selected.value, + progressionPoints: keyframe.progressionPoints.value, + position: Object.fromEntries(keyframe.position.entries()), + rotation: Object.fromEntries(keyframe.rotation.entries()), + cubeGrow: Object.fromEntries(keyframe.cubeGrow.entries()), +}) + +const serializeLayer = (layer: KeyframeLayerData): JsonObject => ({ + layerId: layer.layerId, + id: layer.layerId, + name: layer.name.value, + visible: layer.visible.value, + locked: layer.locked.value, + definedMode: layer.definedMode.value, +}) + +const serializeAnimation = (animation: DcaAnimation): JsonObject => ({ + identifier: animation.identifier, + id: animation.identifier, + name: animation.name.value, + isSkeleton: animation.isSkeleton.value, + nameOverridesOnly: animation.nameOverridesOnly.value, + selected: animation.project.animationTabs.selectedAnimation.value === animation, + time: animation.time.value, + maxTime: animation.maxTime.value, + playing: animation.playing.value, + ikAnchorCubes: animation.ikAnchorCubes.value, + ikDirection: animation.ikDirection.value, + loopData: { + exists: animation.keyframeData.exits.value, + start: animation.keyframeData.start.value, + end: animation.keyframeData.end.value, + duration: animation.keyframeData.duration.value, + }, + keyframes: animation.keyframes.value.map(serializeKeyframe), + layers: animation.keyframeLayers.value.map(serializeLayer), +}) + +const serializeProject = (project: ReturnType, selected = false): JsonObject => ({ + identifier: project.identifier, + id: project.identifier, + name: project.name.value, + selected, + projectSaveType: project.projectSaveType.value, + projectNeedsSaving: project.projectNeedsSaving.value, + modelNeedsSaving: project.model.needsSaving.value, + textureWidth: project.model.textureWidth.value, + textureHeight: project.model.textureHeight.value, + author: project.model.author.value, + metadata: project.model.metadata, + selectedCubes: project.selectedCubeManager.selected.value, + rootCubes: project.model.children.value.map(serializeCube), + animations: project.animationTabs.animations.value.map(serializeAnimation), + selectedAnimation: project.animationTabs.selectedAnimation.value?.identifier ?? null, +}) + +const resolveProject = (studio: StudioContext, selector?: unknown) => { + if (selector === undefined || selector === null || selector === "") { + return studio.getSelectedProject() + } + if (typeof selector === "number") { + const byIndex = studio.projects[selector] + if (!byIndex) throw new Error(`No project exists at index ${selector}`) + return byIndex + } + const text = String(selector) + const byId = studio.projects.find(project => project.identifier === text || project.name.value === text) + if (!byId) throw new Error(`Unable to find project '${text}'`) + return byId +} + +const resolveCube = (project: ReturnType, selector: unknown): DCMCube => { + if (selector instanceof DCMCube) return selector + if (typeof selector !== "string") throw new Error("cube selector must be a string") + const byIdentifier = project.model.identifierCubeMap.get(selector) + if (byIdentifier) return byIdentifier + + const matchesByName = project.model.cubeMap.get(selector) + if (matchesByName?.length === 1) return matchesByName[0] + if (matchesByName && matchesByName.length > 1) throw new Error(`Cube name '${selector}' is ambiguous; use identifier or path`) + + const matchesByPath = project.model.gatherAllCubes().filter(cube => cubePath(cube) === selector) + if (matchesByPath.length === 1) return matchesByPath[0] + if (matchesByPath.length > 1) throw new Error(`Cube path '${selector}' is ambiguous; use identifier`) + + throw new Error(`Unable to find cube '${selector}'`) +} + +const resolveOptionalParent = (project: ReturnType, selector: unknown) => { + if (selector === undefined || selector === null || selector === "") return project.model + return resolveCube(project, selector) +} + +const isDescendantOf = (cube: DCMCube, possibleAncestor: DCMCube) => { + let parent = cube.parent + while (parent instanceof DCMCube) { + if (parent === possibleAncestor) return true + parent = parent.parent + } + return false +} + +const assertValidParent = (cube: DCMCube, parent: DCMCube | ReturnType["model"]) => { + if (!(parent instanceof DCMCube)) return + if (parent === cube || isDescendantOf(parent, cube)) { + throw new Error("Cannot parent a cube to itself or one of its descendants") + } +} + +const collectSubtreePostorder = (cube: DCMCube): DCMCube[] => [ + ...cube.children.value.flatMap(collectSubtreePostorder), + cube, +] + +const resolveAnimation = (project: ReturnType, selector?: unknown): DcaAnimation => { + if (selector === undefined || selector === null || selector === "") { + const selected = project.animationTabs.selectedAnimation.value + if (selected) return selected + throw new Error("No animation is selected") + } + if (typeof selector === "number") { + const byIndex = project.animationTabs.animations.value[selector] + if (!byIndex) throw new Error(`No animation exists at index ${selector}`) + return byIndex + } + const text = String(selector) + const found = project.animationTabs.animations.value.find(animation => animation.identifier === text || animation.name.value === text) + if (!found) throw new Error(`Unable to find animation '${text}'`) + return found +} + +const resolveKeyframe = (animation: DcaAnimation, selector: unknown): DcaKeyframe => { + if (selector instanceof DcaKeyframe) return selector + if (typeof selector === "number") { + const byIndex = animation.keyframes.value[selector] + if (!byIndex) throw new Error(`No keyframe exists at index ${selector}`) + return byIndex + } + const text = asString(selector, "keyframe") + const found = animation.keyframes.value.find(keyframe => keyframe.identifier === text) + if (!found) throw new Error(`Unable to find keyframe '${text}'`) + return found +} + +const getUnusedCubeName = (project: ReturnType, requested?: string) => { + const base = requested?.trim() || "newcube" + if (!project.model.cubeMap.has(base)) return base + let index = 0 + let candidate = base + while (project.model.cubeMap.has(candidate)) { + candidate = `${base}${index++}` + } + return candidate +} + +type CubeParentTarget = DCMCube | ReturnType["model"] + +type CubeCreateInput = { + name: string + dimension: Vec3 + position: Vec3 + offset: Vec3 + rotation: Vec3 + textureOffset: Vec2 + textureMirrored: boolean + cubeGrow: Vec3 + hideChildren: boolean + visible: boolean + locked: boolean + metadata?: Record +} + +type CubeUpdateInput = { + name?: string + dimension?: Vec3 + position?: Vec3 + offset?: Vec3 + rotation?: Vec3 + cubeGrow?: Vec3 + textureOffset?: Vec2 + textureMirrored?: boolean + visible?: boolean + locked?: boolean + hideChildren?: boolean + metadata?: Record + parent?: CubeParentTarget + hasParent: boolean +} + +const parseCubeCreateInput = (project: ReturnType, args: JsonObject): CubeCreateInput => ({ + name: getUnusedCubeName(project, asString(args.name, "name", true)), + dimension: asVec3(args.dimension, "dimension", true) ?? [1, 1, 1], + position: asVec3(args.position, "position", true) ?? [0, 0, 0], + offset: asVec3(args.offset, "offset", true) ?? [0, 0, 0], + rotation: asVec3(args.rotation, "rotation", true) ?? [0, 0, 0], + textureOffset: asVec2(args.textureOffset, "textureOffset", true) ?? [0, 0], + textureMirrored: asBoolean(args.textureMirrored, "textureMirrored", true) ?? false, + cubeGrow: asVec3(args.cubeGrow, "cubeGrow", true) ?? [0, 0, 0], + hideChildren: asBoolean(args.hideChildren, "hideChildren", true) ?? false, + visible: asBoolean(args.visible, "visible", true) ?? true, + locked: asBoolean(args.locked, "locked", true) ?? false, + metadata: args.metadata === undefined ? undefined : asMetadata(args.metadata), +}) + +const parseCubeUpdateInput = (project: ReturnType, args: JsonObject): CubeUpdateInput => ({ + name: asString(args.name, "name", true), + dimension: asVec3(args.dimension, "dimension", true), + position: asVec3(args.position, "position", true), + offset: asVec3(args.offset, "offset", true), + rotation: asVec3(args.rotation, "rotation", true), + cubeGrow: asVec3(args.cubeGrow, "cubeGrow", true), + textureOffset: asVec2(args.textureOffset, "textureOffset", true), + textureMirrored: asBoolean(args.textureMirrored, "textureMirrored", true), + visible: asBoolean(args.visible, "visible", true), + locked: asBoolean(args.locked, "locked", true), + hideChildren: asBoolean(args.hideChildren, "hideChildren", true), + metadata: args.metadata === undefined ? undefined : asMetadata(args.metadata), + parent: hasKey(args, "parent") ? resolveOptionalParent(project, args.parent) : undefined, + hasParent: hasKey(args, "parent"), +}) + +const createCubeFromInput = (project: ReturnType, input: CubeCreateInput) => { + const cube = new DCMCube( + input.name, + input.dimension, + input.position, + input.offset, + input.rotation, + input.textureOffset, + input.textureMirrored, + input.cubeGrow, + [], + project.model, + undefined, + true, + input.hideChildren, + input.visible, + input.locked + ) + if (input.metadata !== undefined) cube.modifyMetadata(input.metadata) + return cube +} + +const reparentCube = (project: ReturnType, cube: DCMCube, parent: DCMCube | typeof project.model) => { + assertValidParent(cube, parent) + project.model.updateMatrixWorld(true) + const locker = new CubeLocker(cube) + cube.parent.deleteChild(cube) + parent.addChild(cube) + project.model.updateMatrixWorld(true) + locker.reconstruct() + return cube +} + +const blobToBase64 = (blob: Blob) => new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error("Unable to read blob as base64")) + reader.onload = () => { + const result = reader.result + if (typeof result !== "string") { + reject(new Error("Unexpected FileReader result")) + return + } + resolve(result.split(",", 2)[1] ?? "") + } + reader.readAsDataURL(blob) +}) + +const base64ToFile = (base64: string, filename: string, mimeType?: string) => { + const binary = atob(base64.replace(/^data:[^,]+,/, "")) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new File([bytes], filename, { type: mimeType ?? "application/octet-stream" }) +} + +const updateCubeFields = (project: ReturnType, cube: DCMCube, input: CubeUpdateInput) => { + project.model.undoRedoHandler.startBatchActions() + try { + if (input.name !== undefined) cube.name.value = input.name + if (input.dimension !== undefined) cube.dimension.value = input.dimension + if (input.position !== undefined) cube.position.value = input.position + if (input.offset !== undefined) cube.offset.value = input.offset + if (input.rotation !== undefined) cube.rotation.value = input.rotation + if (input.cubeGrow !== undefined) cube.cubeGrow.value = input.cubeGrow + if (input.textureOffset !== undefined) cube.textureOffset.value = input.textureOffset + if (input.textureMirrored !== undefined) cube.textureMirrored.value = input.textureMirrored + if (input.visible !== undefined) cube.visible.value = input.visible + if (input.locked !== undefined) cube.locked.value = input.locked + if (input.hideChildren !== undefined) cube.hideChildren.value = input.hideChildren + if (input.metadata !== undefined) cube.modifyMetadata(input.metadata) + if (input.hasParent && input.parent !== undefined) { + reparentCube(project, cube, input.parent) + } + } finally { + project.model.undoRedoHandler.endBatchActions("MCP Cube Updated") + } +} + +const deleteCubeWithMode = (project: ReturnType, cube: DCMCube, keepChildren: boolean) => { + const cubesToDelete = keepChildren ? [cube] : collectSubtreePostorder(cube) + const deletedIds = cubesToDelete.map(child => child.identifier) + + if (keepChildren) { + project.model.updateMatrixWorld(true) + const lockers = cube.children.value.map(child => new CubeLocker(child)) + cube.children.value.forEach(child => { + child.parent.deleteChild(child) + cube.parent.addChild(child) + }) + project.model.updateMatrixWorld(true) + lockers.forEach(locker => locker.reconstruct()) + } + + cubesToDelete.forEach(child => { + child.selected.value = false + child.mouseHover.value = false + }) + cubesToDelete.forEach(child => { + if (!child.destroyed) child.fullyDelete() + }) + project.selectedCubeManager.selected.value = project.selectedCubeManager.selected.value.filter(id => !deletedIds.includes(id)) + return deletedIds +} + +type LoopDataUpdate = { + exists?: boolean + start?: number + end?: number + duration?: number +} + +type AnimationUpdateInput = { + name?: string + isSkeleton?: boolean + nameOverridesOnly?: boolean + time?: number + playing?: boolean + ikDirection?: "upwards" | "downwards" + ikAnchorCubes?: string[] + loopData?: LoopDataUpdate +} + +const parseAnimationUpdates = (project: ReturnType, args: JsonObject): AnimationUpdateInput => { + const ikDirection = asString(args.ikDirection, "ikDirection", true) + if (ikDirection !== undefined && ikDirection !== "upwards" && ikDirection !== "downwards") { + throw new Error("ikDirection must be upwards or downwards") + } + let loopData: LoopDataUpdate | undefined + if (args.loopData !== undefined) { + if (!isObject(args.loopData)) throw new Error("loopData must be an object") + loopData = { + exists: asBoolean(args.loopData.exists, "loopData.exists", true), + start: asNumber(args.loopData.start, "loopData.start", true), + end: asNumber(args.loopData.end, "loopData.end", true), + duration: asNumber(args.loopData.duration, "loopData.duration", true), + } + } + return { + name: asString(args.name, "name", true), + isSkeleton: asBoolean(args.isSkeleton, "isSkeleton", true), + nameOverridesOnly: asBoolean(args.nameOverridesOnly, "nameOverridesOnly", true), + time: asNumber(args.time, "time", true), + playing: asBoolean(args.playing, "playing", true), + ikDirection, + ikAnchorCubes: args.ikAnchorCubes === undefined ? undefined : asArray(args.ikAnchorCubes, "ikAnchorCubes").map(selector => resolveCube(project, selector).identifier), + loopData, + } +} + +const applyAnimationUpdates = (animation: DcaAnimation, input: AnimationUpdateInput) => { + animation.undoRedoHandler.startBatchActions() + try { + if (input.name !== undefined) animation.name.value = input.name + if (input.isSkeleton !== undefined) animation.isSkeleton.value = input.isSkeleton + if (input.nameOverridesOnly !== undefined) animation.nameOverridesOnly.value = input.nameOverridesOnly + if (input.time !== undefined) animation.time.value = input.time + if (input.playing !== undefined) animation.playing.value = input.playing + if (input.ikDirection !== undefined) animation.ikDirection.value = input.ikDirection + if (input.ikAnchorCubes !== undefined) animation.ikAnchorCubes.value = input.ikAnchorCubes + if (input.loopData !== undefined) { + if (input.loopData.exists !== undefined) animation.keyframeData.exits.value = input.loopData.exists + if (input.loopData.start !== undefined) animation.keyframeData.start.value = input.loopData.start + if (input.loopData.end !== undefined) animation.keyframeData.end.value = input.loopData.end + if (input.loopData.duration !== undefined) animation.keyframeData.duration.value = input.loopData.duration + } + } finally { + animation.undoRedoHandler.endBatchActions("MCP Animation Updated") + } +} + +type TransformUpdate = readonly [string, Vec3 | null] + +const asTransformUpdates = (value: unknown, name: string): TransformUpdate[] => { + if (!isObject(value)) throw new Error(`${name} must be an object keyed by cube name`) + return Object.entries(value).map(([key, entry]) => [key, entry === null ? null : asVec3(entry, `${name}.${key}`)!]) +} + +const applyTransformUpdates = (map: Map, updates: readonly TransformUpdate[]) => { + updates.forEach(([key, value]) => { + if (value === null) { + map.delete(key) + } else { + map.set(key, value) + } + }) +} + +type KeyframeUpdateInput = { + layerId?: number + startTime?: number + duration?: number + selected?: boolean + progressionPoints?: readonly ProgressionPoint[] + position?: TransformUpdate[] + rotation?: TransformUpdate[] + cubeGrow?: TransformUpdate[] +} + +const parseKeyframeUpdates = (args: JsonObject): KeyframeUpdateInput => ({ + layerId: asNumber(args.layerId, "layerId", true), + startTime: asNumber(args.startTime, "startTime", true), + duration: asNumber(args.duration, "duration", true), + selected: asBoolean(args.selected, "selected", true), + progressionPoints: args.progressionPoints === undefined ? undefined : asProgressionPoints(args.progressionPoints), + position: args.position === undefined ? undefined : asTransformUpdates(args.position, "position"), + rotation: args.rotation === undefined ? undefined : asTransformUpdates(args.rotation, "rotation"), + cubeGrow: args.cubeGrow === undefined ? undefined : asTransformUpdates(args.cubeGrow, "cubeGrow"), +}) + +const applyKeyframeUpdates = (keyframe: DcaKeyframe, input: KeyframeUpdateInput) => { + keyframe.animation.undoRedoHandler.startBatchActions() + try { + if (input.layerId !== undefined) keyframe.layerId.value = input.layerId + if (input.startTime !== undefined) keyframe.startTime.value = input.startTime + if (input.duration !== undefined) keyframe.duration.value = input.duration + if (input.selected !== undefined) keyframe.selected.value = input.selected + if (input.progressionPoints !== undefined) keyframe.progressionPoints.value = input.progressionPoints + if (input.position !== undefined) applyTransformUpdates(keyframe.position, input.position) + if (input.rotation !== undefined) applyTransformUpdates(keyframe.rotation, input.rotation) + if (input.cubeGrow !== undefined) applyTransformUpdates(keyframe.cubeGrow, input.cubeGrow) + } finally { + keyframe.animation.undoRedoHandler.endBatchActions("MCP Keyframe Updated") + } +} + +const commandLog = (history: readonly { type?: "command" | "error", message: string, times?: number }[]) => + history.slice(-50).map(entry => ({ type: entry.type ?? "log", message: entry.message, times: entry.times ?? 1 })) + +const asRgbOrRgba255 = (value: unknown, name: string): [number, number, number, number] => { + const arr = asArray(value, name) + if (arr.length !== 3 && arr.length !== 4) { + throw new Error(`${name} must be [r,g,b] or [r,g,b,a] with integer channel values 0-255`) + } + for (let i = 0; i < arr.length; i++) { + const n = asNumber(arr[i], `${name}[${i}]`) + if (!Number.isInteger(n) || n < 0 || n > 255) { + throw new Error(`${name}[${i}] must be an integer from 0 to 255`) + } + } + const r = asNumber(arr[0], `${name}[0]`) + const g = asNumber(arr[1], `${name}[1]`) + const b = asNumber(arr[2], `${name}[2]`) + const a = arr.length === 4 ? asNumber(arr[3], `${name}[3]`) : 255 + return [r, g, b, a] +} + +const parseFaceIndexesForPaint = (value: unknown): readonly number[] | undefined => { + if (value === undefined || value === null || value === "all") { + return undefined + } + if (typeof value === "string") { + if (value.trim().toLowerCase() === "all") { + return undefined + } + return [resolvePaintFaceToken(value, "faces")] + } + const arr = asArray(value, "faces") + return arr.map((entry, i) => resolvePaintFaceToken(entry, `faces[${i}]`)) +} + +const resolvePaintTargetTexture = (project: ReturnType, textureId: unknown) => { + const id = textureId !== undefined && textureId !== null && String(textureId) !== "" ? String(textureId) : undefined + if (id !== undefined) { + return project.textureManager.findTexture(id) + } + const selectedIds = project.textureManager.selectedGroup.value.textures.value + if (selectedIds.length > 0) { + return project.textureManager.findTexture(selectedIds[0]!) + } + const defaultIds = project.textureManager.defaultGroup.textures.value + if (defaultIds.length > 0) { + return project.textureManager.findTexture(defaultIds[0]!) + } + throw new Error("No texture to paint: add a texture to the project or select a texture group that includes one.") +} + +const runAction = async (action: McpActionName, args: McpArgs, { studio }: ActionContext): Promise => { + const input = args as JsonObject + switch (action) { + case "ping": + return { ok: true, tab: studio.settingsOpen ? "Options" : studio.activeTab.name, projectCount: studio.projects.length } + + case "get_action_schema": + return { + actions: MCP_ACTIONS.map(name => ({ name, description: MCP_ACTION_DESCRIPTIONS[name], args: MCP_ACTION_SCHEMA[name], endpoint: `/api/mcp/action/${name}` })), + paintCubeTextureFaceReference: PAINT_FACE_DEFINITIONS.map(def => ({ + index: def.index, + threeAxis: def.threeAxis, + minecraft: def.minecraft, + aliases: [...def.aliases], + })), + } + + case "get_state": { + const selectedProject = studio.hasProject ? studio.getSelectedProject() : null + return { + activeTab: studio.settingsOpen ? "Options" : studio.activeTab.name, + projectCount: studio.projects.length, + selectedProject: selectedProject?.identifier ?? null, + projects: studio.projects.map(project => serializeProject(project, project === selectedProject)), + commands: asBoolean(input.includeCommands, "includeCommands", true) ? { + model: selectedProject?.commandRoot.commands.map(command => command.formatToString()) ?? [], + animator: selectedProject?.animatorCommandRoot.commands.map(command => command.formatToString()) ?? [], + } : undefined, + history: asBoolean(input.includeHistory, "includeHistory", true) && selectedProject ? { + model: selectedProject.model.undoRedoHandler.history.value.slice(-20).map(batch => ({ reason: batch.reason, time: batch.time, actionCount: batch.actions.length })), + animation: selectedProject.animationTabs.selectedAnimation.value?.undoRedoHandler.history.value.slice(-20).map(batch => ({ reason: batch.reason, time: batch.time, actionCount: batch.actions.length })) ?? [], + } : undefined, + } + } + + case "set_active_tab": { + const tab = asString(input.tab, "tab") + if (tab === "Options") { + studio.setSettingsOpen(true) + return { activeTab: "Options" } + } + const target = StudioTabs.find(entry => entry.name.toLowerCase() === tab.toLowerCase()) + if (!target) throw new Error(`Unknown Studio tab '${tab}'`) + if (target !== StudioTabs[0] && !studio.hasProject) studio.getSelectedProject() + studio.setSettingsOpen(false) + studio.setActiveTab(target) + return { activeTab: target.name } + } + + case "new_project": { + const name = asString(input.name, "name", true) + const project = newProject() + if (name !== undefined) project.name.value = name + studio.addProject(project) + return serializeProject(project, true) + } + + case "list_projects": + return { projects: studio.projects.map(project => ({ identifier: project.identifier, id: project.identifier, name: project.name.value, selected: studio.hasProject && studio.getSelectedProject() === project })) } + + case "select_project": { + const project = resolveProject(studio, input.projectId ?? input.project) + studio.selectProject(project) + return serializeProject(project, true) + } + + case "rename_project": { + const project = resolveProject(studio, input.projectId ?? input.project) + project.name.value = asString(input.name, "name") + return serializeProject(project, studio.getSelectedProject() === project) + } + + case "close_project": { + const project = resolveProject(studio, input.projectId ?? input.project) + studio.removeProject(project) + return { closed: project.identifier, projects: studio.projects.filter(entry => entry !== project).map(entry => ({ id: entry.identifier, name: entry.name.value })) } + } + + case "set_model_metadata": { + const project = resolveProject(studio, input.projectId) + const metadata = asMetadata(input.metadata) + project.model.modifyMetadata(metadata) + return { metadata: project.model.metadata } + } + + case "set_texture_size": { + const project = resolveProject(studio, input.projectId) + const width = asNumber(input.width, "width") + const height = asNumber(input.height, "height") + project.model.textureWidth.value = width + project.model.textureHeight.value = height + return { width: project.model.textureWidth.value, height: project.model.textureHeight.value } + } + + case "list_cubes": { + const project = resolveProject(studio, input.projectId) + const includeChildren = asBoolean(input.includeChildren, "includeChildren", true) ?? true + return { cubes: includeChildren ? project.model.children.value.map(serializeCube) : project.model.gatherAllCubes().map(serializeCubeFlat) } + } + + case "select_cubes": { + const project = resolveProject(studio, input.projectId) + const mode = asString(input.mode, "mode", true) ?? "replace" + if (!["replace", "add", "remove"].includes(mode)) throw new Error("mode must be replace, add, or remove") + const ids = asArray(input.cubes, "cubes").map(selector => resolveCube(project, selector).identifier) + const current = project.selectedCubeManager.selected.value + if (mode === "replace") project.selectedCubeManager.selected.value = ids + if (mode === "add") project.selectedCubeManager.selected.value = Array.from(new Set([...current, ...ids])) + if (mode === "remove") project.selectedCubeManager.selected.value = current.filter(id => !ids.includes(id)) + return { selectedCubes: project.selectedCubeManager.selected.value } + } + + case "create_cube": { + const project = resolveProject(studio, input.projectId) + const cubeInput = parseCubeCreateInput(project, input) + const parent = input.siblingOf !== undefined ? resolveCube(project, input.siblingOf).parent : resolveOptionalParent(project, input.parent) + project.model.undoRedoHandler.startBatchActions() + try { + const cube = createCubeFromInput(project, cubeInput) + parent.addChild(cube) + project.selectedCubeManager.selected.value = [cube.identifier] + return serializeCube(cube) + } finally { + project.model.undoRedoHandler.endBatchActions("MCP Cube Created") + } + } + + case "update_cube": { + const project = resolveProject(studio, input.projectId) + const cube = resolveCube(project, input.cube) + const update = parseCubeUpdateInput(project, input) + if (update.hasParent && update.parent !== undefined) assertValidParent(cube, update.parent) + updateCubeFields(project, cube, update) + return serializeCube(cube) + } + + case "set_cube_shape": { + const project = resolveProject(studio, input.projectId) + const cube = resolveCube(project, input.cube) + const hasDim = input.dimension !== undefined + const hasGrow = input.cubeGrow !== undefined + if (!hasDim && !hasGrow) { + throw new Error("set_cube_shape requires at least one of dimension or cubeGrow") + } + const dimension = hasDim ? asVec3(input.dimension, "dimension") : undefined + const cubeGrow = hasGrow ? asVec3(input.cubeGrow, "cubeGrow") : undefined + project.model.undoRedoHandler.startBatchActions() + try { + if (dimension !== undefined) cube.dimension.value = dimension + if (cubeGrow !== undefined) cube.cubeGrow.value = cubeGrow + } finally { + project.model.undoRedoHandler.endBatchActions("MCP Cube Shape Updated") + } + return serializeCube(cube) + } + + case "paint_cube_texture": { + const project = resolveProject(studio, input.projectId) + const cube = resolveCube(project, input.cube) + const rgba = asRgbOrRgba255(input.color, "color") + const faceIndexes = parseFaceIndexesForPaint(input.faces) + const texture = resolvePaintTargetTexture(project, input.textureId) + const painted = await paintCubeFacesOntoRasterTexture(project.textureManager, texture, cube, rgba, faceIndexes) + return { + cube: cube.identifier, + cubeName: cube.name.value, + texture: texture.identifier, + textureName: texture.name.value, + facesPainted: painted.facesPainted, + rects: painted.rects, + } + } + + case "delete_cube": { + const project = resolveProject(studio, input.projectId) + const cubeSelectors = input.cubes !== undefined ? asArray(input.cubes, "cubes") : [input.cube] + const cubes = Array.from(new Set(cubeSelectors.map(selector => resolveCube(project, selector)))) + const keepChildren = asBoolean(input.keepChildren, "keepChildren", true) ?? false + const roots = keepChildren ? cubes : cubes.filter(cube => !cubes.some(other => other !== cube && isDescendantOf(cube, other))) + const deleted: string[] = [] + project.model.undoRedoHandler.startBatchActions() + try { + roots.forEach(cube => deleted.push(...deleteCubeWithMode(project, cube, keepChildren))) + } finally { + project.model.undoRedoHandler.endBatchActions("MCP Cube Deleted") + } + return { deleted, keepChildren } + } + + case "reparent_cube": { + const project = resolveProject(studio, input.projectId) + const cube = resolveCube(project, input.cube) + const parent = resolveOptionalParent(project, input.parent) + project.model.undoRedoHandler.startBatchActions() + try { + reparentCube(project, cube, parent) + } finally { + project.model.undoRedoHandler.endBatchActions("MCP Cube Reparented") + } + return serializeCube(cube) + } + + case "duplicate_cube": { + const project = resolveProject(studio, input.projectId) + const source = resolveCube(project, input.cube) + const parent = resolveOptionalParent(project, input.parent) + const name = asString(input.name, "name", true) + project.model.undoRedoHandler.startBatchActions() + try { + const clone = source.cloneCube(project.model) + if (name !== undefined) clone.name.value = getUnusedCubeName(project, name) + parent.addChild(clone) + project.selectedCubeManager.selected.value = [clone.identifier] + return serializeCube(clone) + } finally { + project.model.undoRedoHandler.endBatchActions("MCP Cube Duplicated") + } + } + + case "run_model_command": { + const project = resolveProject(studio, input.projectId) + project.commandRoot.lastCommandErrorOutput.value = "" + project.commandRoot.runCommand(asString(input.command, "command"), false) + const error = project.commandRoot.lastCommandErrorOutput.value + if (error) throw new Error(error) + return { log: commandLog(project.commandRoot.logHistory.value) } + } + + case "run_animator_command": { + const project = resolveProject(studio, input.projectId) + project.animatorCommandRoot.lastCommandErrorOutput.value = "" + project.animatorCommandRoot.runCommand(asString(input.command, "command"), false) + const error = project.animatorCommandRoot.lastCommandErrorOutput.value + if (error) throw new Error(error) + return { log: commandLog(project.animatorCommandRoot.logHistory.value) } + } + + case "list_animations": { + const project = resolveProject(studio, input.projectId) + return { animations: project.animationTabs.animations.value.map(serializeAnimation) } + } + + case "create_animation": { + const project = resolveProject(studio, input.projectId) + const name = asString(input.name, "name", true) + const isSkeleton = asBoolean(input.isSkeleton, "isSkeleton", true) + const shouldSelect = asBoolean(input.select, "select", true) !== false + const animation = DcaAnimation.createNew(project) + if (name !== undefined) animation.name.value = name + if (isSkeleton !== undefined) animation.isSkeleton.value = isSkeleton + project.animationTabs.addAnimation(animation) + if (!shouldSelect) { + project.animationTabs.selectedAnimation.value = null + } + return serializeAnimation(animation) + } + + case "select_animation": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + project.animationTabs.selectedAnimation.value = animation + return serializeAnimation(animation) + } + + case "update_animation": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + applyAnimationUpdates(animation, parseAnimationUpdates(project, input)) + return serializeAnimation(animation) + } + + case "delete_animation": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + project.animationTabs.animations.value = project.animationTabs.animations.value.filter(entry => entry !== animation) + project.animationTabs.tabs.value = project.animationTabs.tabs.value.filter(id => id !== animation.identifier) + if (project.animationTabs.selectedAnimation.value === animation) project.animationTabs.selectedAnimation.value = null + return { deleted: animation.identifier } + } + + case "list_keyframes": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + return { keyframes: animation.keyframes.value.map(serializeKeyframe) } + } + + case "create_keyframe": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const layerId = asNumber(input.layerId, "layerId", true) ?? 0 + const startTime = asNumber(input.startTime, "startTime", true) + const duration = asNumber(input.duration, "duration", true) + const selected = asBoolean(input.selected, "selected", true) + const rotation = input.rotation !== undefined ? asTransformMap(input.rotation, "rotation") : undefined + const position = input.position !== undefined ? asTransformMap(input.position, "position") : undefined + const cubeGrow = input.cubeGrow !== undefined ? asTransformMap(input.cubeGrow, "cubeGrow") : undefined + const progressionPoints = input.progressionPoints !== undefined ? asProgressionPoints(input.progressionPoints) : undefined + const keyframe = animation.createKeyframe( + layerId, + undefined, + startTime, + duration, + selected, + rotation, + position, + cubeGrow, + progressionPoints + ) + return serializeKeyframe(keyframe) + } + + case "update_keyframe": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const keyframe = resolveKeyframe(animation, input.keyframe) + applyKeyframeUpdates(keyframe, parseKeyframeUpdates(input)) + return serializeKeyframe(keyframe) + } + + case "delete_keyframe": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const keyframe = resolveKeyframe(animation, input.keyframe) + keyframe.delete() + return { deleted: keyframe.identifier } + } + + case "set_keyframe_transform": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const keyframe = resolveKeyframe(animation, input.keyframe) + const cube = resolveCube(project, input.cube) + const cubeKey = asBoolean(input.useCubeName, "useCubeName", true) === false ? cube.identifier : cube.name.value + const hasPosition = hasKey(input, "position") + const hasRotation = hasKey(input, "rotation") + const hasCubeGrow = hasKey(input, "cubeGrow") + const position = hasPosition ? input.position === null ? null : asVec3(input.position, "position") : undefined + const rotation = hasRotation ? input.rotation === null ? null : asVec3(input.rotation, "rotation") : undefined + const cubeGrow = hasCubeGrow ? input.cubeGrow === null ? null : asVec3(input.cubeGrow, "cubeGrow") : undefined + keyframe.animation.undoRedoHandler.startBatchActions() + try { + if (hasPosition) position === null ? keyframe.position.delete(cubeKey) : keyframe.position.set(cubeKey, position!) + if (hasRotation) rotation === null ? keyframe.rotation.delete(cubeKey) : keyframe.rotation.set(cubeKey, rotation!) + if (hasCubeGrow) cubeGrow === null ? keyframe.cubeGrow.delete(cubeKey) : keyframe.cubeGrow.set(cubeKey, cubeGrow!) + } finally { + keyframe.animation.undoRedoHandler.endBatchActions("MCP Keyframe Transform Updated") + } + return serializeKeyframe(keyframe) + } + + case "list_keyframe_layers": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + return { layers: animation.keyframeLayers.value.map(serializeLayer) } + } + + case "create_keyframe_layer": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const existingIds = animation.keyframeLayers.value.map(layer => layer.layerId) + const layerId = asNumber(input.layerId, "layerId", true) ?? (existingIds.length === 0 ? 0 : Math.max(...existingIds) + 1) + if (animation.keyframeLayers.value.some(layer => layer.layerId === layerId)) throw new Error(`Layer ${layerId} already exists`) + const name = asString(input.name, "name", true) ?? `Layer ${layerId}` + const visible = asBoolean(input.visible, "visible", true) ?? true + const locked = asBoolean(input.locked, "locked", true) ?? false + const definedMode = asBoolean(input.definedMode, "definedMode", true) ?? false + const layer = new KeyframeLayerData( + animation, + layerId, + name, + visible, + locked, + definedMode + ) + animation.keyframeLayers.value = animation.keyframeLayers.value.concat(layer) + return serializeLayer(layer) + } + + case "update_keyframe_layer": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const layerId = asNumber(input.layerId, "layerId") + const layer = animation.keyframeLayers.value.find(entry => entry.layerId === layerId) + if (!layer) throw new Error(`Unable to find keyframe layer ${layerId}`) + const name = asString(input.name, "name", true) + const visible = asBoolean(input.visible, "visible", true) + const locked = asBoolean(input.locked, "locked", true) + const definedMode = asBoolean(input.definedMode, "definedMode", true) + if (name !== undefined) layer.name.value = name + if (visible !== undefined) layer.visible.value = visible + if (locked !== undefined) layer.locked.value = locked + if (definedMode !== undefined) layer.definedMode.value = definedMode + return serializeLayer(layer) + } + + case "delete_keyframe_layer": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const layerId = asNumber(input.layerId, "layerId") + animation.deleteKeyframesLayers([layerId]) + return { deletedLayerId: layerId } + } + + case "set_temporary_parent": { + const project = resolveProject(studio, input.projectId) + const animation = resolveAnimation(project, input.animation) + const cube = resolveCube(project, input.cube) + if (input.parent === null || input.parent === undefined || input.parent === "") { + animation.tempoaryParenting.delete(cube.identifier) + return { cube: cube.identifier, parent: null } + } + const parent = resolveCube(project, input.parent) + animation.tempoaryParenting.set(cube.identifier, parent.identifier) + return { cube: cube.identifier, parent: parent.identifier } + } + + case "export_asset": { + const project = resolveProject(studio, input.projectId) + const format = asString(input.format, "format") + if (format === "dcproj") { + const blob = await writeDcProj(project) + return { filename: `${project.name.value}.dcproj`, mimeType: "application/zip", base64: await blobToBase64(blob) } + } + if (format === "dcm") { + const blob = await writeModelWithFormat(project.model, "blob") + return { filename: `${project.name.value}.dcm`, mimeType: "application/zip", base64: await blobToBase64(blob) } + } + if (format === "dca") { + const animation = resolveAnimation(project, input.animation) + const blob = await writeDCAAnimationWithFormat(animation, "blob") + return { filename: `${animation.name.value}.dca`, mimeType: "application/zip", base64: await blobToBase64(blob) } + } + throw new Error("format must be dcproj, dcm, or dca") + } + + case "import_asset": { + const filename = asString(input.filename, "filename") + const base64 = asString(input.base64, "base64") + const mimeType = asString(input.mimeType, "mimeType", true) + const file = base64ToFile(base64, filename, mimeType) + if (filename.endsWith(".dca")) { + const project = resolveProject(studio, input.projectId) + const animation = await loadUnknownAnimation(project, removeFileExtension(filename), await file.arrayBuffer()) + project.animationTabs.addAnimation(animation) + return serializeAnimation(animation) + } + const project = await createProject(createReadableFile(file)) + studio.addProject(project) + if (asBoolean(input.select, "select", true) === false) { + const selected = studio.projects.find(entry => entry !== project) + if (selected) studio.selectProject(selected) + } + return serializeProject(project, studio.getSelectedProject() === project) + } + } +} + +type ClientCredentials = ReturnType + +const postResult = async (credentials: ClientCredentials, result: McpCommandResult) => { + const response = await fetch("/api/mcp/client/result", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...credentials, result }), + }) + if (!response.ok) { + const text = await response.text() + throw new Error(`MCP result post failed (${response.status}): ${text}`) + } +} + +const executeQueuedCommand = async (credentials: ClientCredentials, command: McpQueuedCommand, context: ActionContext) => { + let commandResult: McpCommandResult + try { + const result = await runAction(command.action, command.args, context) + commandResult = { requestId: command.id, ok: true, result } + } catch (error) { + commandResult = { + requestId: command.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + } + } + + try { + await postResult(credentials, commandResult) + } catch (error) { + console.error("Unable to report MCP command result", error) + } +} + +const McpStudioBridge = () => { + const studio = useStudio() + const context = useMemo(() => ({ studio }), [studio]) + const contextRef = useRef(context) + + useEffect(() => { + contextRef.current = context + }, [context]) + + useEffect(() => { + if (typeof window === "undefined") return + + const credentials = getClientCredentials() + let cancelled = false + let inFlight = false + + const visibilityParams = () => { + const params = new URLSearchParams({ + maxCommands: String(MAX_COMMANDS_PER_POLL), + visible: String(document.visibilityState === "visible"), + focused: String(document.hasFocus()), + }) + return params.toString() + } + + const poll = async () => { + if (cancelled || inFlight) return + inFlight = true + try { + const response = await fetch(`/api/mcp/client/poll?${visibilityParams()}`, { + cache: "no-store", + headers: { + "X-DCS-MCP-Client-Id": credentials.clientId, + "X-DCS-MCP-Client-Secret": credentials.secret, + }, + }) + if (response.ok) { + const body = await response.json() as { commands?: McpQueuedCommand[] } + const commands = body.commands ?? [] + for (const command of commands) { + await executeQueuedCommand(credentials, command, contextRef.current) + } + } else { + const text = await response.text() + console.warn(`Unable to poll MCP commands (${response.status}): ${text}`) + } + } catch (error) { + if (!cancelled) console.warn("Unable to poll MCP commands", error) + } finally { + inFlight = false + } + } + + const pollSoon = () => void poll() + void poll() + const interval = window.setInterval(() => void poll(), POLL_INTERVAL_MS) + window.addEventListener("focus", pollSoon) + window.addEventListener("pageshow", pollSoon) + document.addEventListener("visibilitychange", pollSoon) + return () => { + cancelled = true + window.clearInterval(interval) + window.removeEventListener("focus", pollSoon) + window.removeEventListener("pageshow", pollSoon) + document.removeEventListener("visibilitychange", pollSoon) + } + }, []) + + return null +} + +export default McpStudioBridge diff --git a/apps/studio/src/studio/mcp/paintFaceAliases.ts b/apps/studio/src/studio/mcp/paintFaceAliases.ts new file mode 100644 index 00000000..9e60d0ea --- /dev/null +++ b/apps/studio/src/studio/mcp/paintFaceAliases.ts @@ -0,0 +1,269 @@ +/** + * Face indices 0-5 match {@link TextureManager.getCubeFacePixelRects} / Three.js box material order: + * 0 +X, 1 −X, 2 +Y, 3 −Y, 4 +Z, 5 −Z (Minecraft-style: east, west, up, down, south, north). + */ + +export const normalizePaintFaceAliasKey = (raw: string) => + raw + .trim() + .toLowerCase() + .replace(/\./g, "_") + .replace(/[\s/\\]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + +/** Single source of truth: index + every accepted string token (before normalization). */ +export const PAINT_FACE_DEFINITIONS: readonly { + readonly index: 0 | 1 | 2 | 3 | 4 | 5 + readonly threeAxis: string + readonly minecraft: string + readonly aliases: readonly string[] +}[] = [ + { + index: 0, + threeAxis: "+X", + minecraft: "east", + aliases: [ + "0", + "+x", + "x+", + "x_plus", + "plus_x", + "positive_x", + "pos_x", + "px", + "east", + "east_face", + "e", + "right", + "rhs", + "uv_east", + "uv_px", + "uv_right", + "uv_pos_x", + "side_east", + "face_east", + "cube_face_east", + "f0", + "face0", + "face_0", + "xmax", + "max_x", + "maxx", + "xp", + ], + }, + { + index: 1, + threeAxis: "-X", + minecraft: "west", + aliases: [ + "1", + "-x", + "x-", + "x_minus", + "minus_x", + "negative_x", + "neg_x", + "nx", + "west", + "west_face", + "w", + "left", + "lhs", + "uv_west", + "uv_nx", + "uv_left", + "uv_neg_x", + "side_west", + "face_west", + "cube_face_west", + "f1", + "face1", + "face_1", + "xmin", + "min_x", + "minx", + "xm", + ], + }, + { + index: 2, + threeAxis: "+Y", + minecraft: "up", + aliases: [ + "2", + "+y", + "y+", + "y_plus", + "plus_y", + "positive_y", + "pos_y", + "py", + "up", + "top", + "u", + "uv_up", + "uv_py", + "uv_top", + "uv_pos_y", + "side_up", + "face_up", + "cube_face_up", + "ceiling", + "upper", + "ymax", + "max_y", + "maxy", + "yp", + "f2", + "face2", + "face_2", + ], + }, + { + index: 3, + threeAxis: "-Y", + minecraft: "down", + aliases: [ + "3", + "-y", + "y-", + "y_minus", + "minus_y", + "negative_y", + "neg_y", + "ny", + "down", + "bottom", + "d", + "floor", + "uv_down", + "uv_ny", + "uv_bottom", + "uv_neg_y", + "side_down", + "face_down", + "cube_face_down", + "f3", + "face3", + "face_3", + "lower", + "ymin", + "min_y", + "miny", + "ym", + ], + }, + { + index: 4, + threeAxis: "+Z", + minecraft: "south", + aliases: [ + "4", + "+z", + "z+", + "z_plus", + "plus_z", + "positive_z", + "pos_z", + "pz", + "south", + "south_face", + "s", + "front", + "forward", + "fwd", + "uv_south", + "uv_pz", + "uv_front", + "uv_forward", + "uv_pos_z", + "side_south", + "face_south", + "cube_face_south", + "f4", + "face4", + "face_4", + "zmax", + "max_z", + "maxz", + "zp", + "fore", + ], + }, + { + index: 5, + threeAxis: "-Z", + minecraft: "north", + aliases: [ + "5", + "-z", + "z-", + "z_minus", + "minus_z", + "negative_z", + "neg_z", + "nz", + "north", + "north_face", + "n", + "back", + "backward", + "rear", + "uv_north", + "uv_nz", + "uv_back", + "uv_rear", + "uv_neg_z", + "side_north", + "face_north", + "cube_face_north", + "f5", + "face5", + "face_5", + "zmin", + "min_z", + "minz", + "zm", + "aft", + ], + }, +] as const + +export const PAINT_FACE_ALIAS_TO_INDEX: ReadonlyMap = (() => { + const map = new Map() + for (const def of PAINT_FACE_DEFINITIONS) { + for (const name of def.aliases) { + map.set(normalizePaintFaceAliasKey(name), def.index) + } + } + return map +})() + +export const resolvePaintFaceToken = (token: unknown, positionLabel: string): number => { + if (typeof token === "number") { + if (!Number.isInteger(token) || token < 0 || token > 5) { + throw new Error(`${positionLabel} must be an integer face index from 0 to 5`) + } + return token + } + if (typeof token === "string") { + const key = normalizePaintFaceAliasKey(token) + if (/^[0-5]$/.test(key)) { + return Number(key) + } + const idx = PAINT_FACE_ALIAS_TO_INDEX.get(key) + if (idx !== undefined) { + return idx + } + throw new Error( + `${positionLabel}: unknown face "${token}". Use 0-5 or an alias (east/west/up/down/south/north, px/nx/py/ny/pz/nz, uv_east, front/back, ...). See paintFaceAliases.ts.`, + ) + } + throw new Error(`${positionLabel} must be a face index (0-5) or a string alias`) +} + +/** Compact line for MCP_ACTION_SCHEMA. */ +export const PAINT_FACE_ALIASES_SCHEMA_HINT = + "all | one face string | array; indices 0-5 = +X,-X,+Y,-Y,+Z,-Z (east,west,up,down,south,north). Many aliases: px/nx/py/ny/pz/nz, right/left/top/bottom/front/back, uv_east...uv_north, face0...face5, pos_x, forward, ... - full list in paintFaceAliases.PAINT_FACE_DEFINITIONS" diff --git a/apps/studio/src/studio/mcp/types.ts b/apps/studio/src/studio/mcp/types.ts new file mode 100644 index 00000000..4cbc06c7 --- /dev/null +++ b/apps/studio/src/studio/mcp/types.ts @@ -0,0 +1,43 @@ +import type { McpActionName } from "../../../../../packages/mcp-contract/index.js" + +export { MCP_ACTION_DESCRIPTIONS, MCP_ACTION_SCHEMA, MCP_ACTIONS, type McpActionName } from "../../../../../packages/mcp-contract/index.js" + +export type McpJson = null | boolean | number | string | McpJson[] | { [key: string]: McpJson } +export type McpArgs = Record + +export type McpQueuedCommand = { + id: string + action: McpActionName + args: McpArgs + createdAt: number +} + +export type McpCommandSuccess = { + requestId: string + ok: true + result: unknown +} + +export type McpCommandFailure = { + requestId: string + ok: false + error: string + stack?: string +} + +export type McpCommandResult = McpCommandSuccess | McpCommandFailure + +export type McpExecuteRequest = { + action: McpActionName + args?: McpArgs + timeoutMs?: number +} + +export type McpExecuteResponse = { + requestId: string + action: McpActionName + result: unknown +} + +export type Vec2 = readonly [number, number] +export type Vec3 = readonly [number, number, number] diff --git a/apps/studio/src/studio/util/UseWhenAction.ts b/apps/studio/src/studio/util/UseWhenAction.ts new file mode 100644 index 00000000..7a2fa7fb --- /dev/null +++ b/apps/studio/src/studio/util/UseWhenAction.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react" + +export const useWhenAction = (action: "create_new_model" | "last_remote_repo_project", fn: () => void | Promise) => { + const handled = useRef(false) + useEffect(() => { + if (typeof window === "undefined") return + const urlAction = new URLSearchParams(window.location.search).get("action") + if (action === urlAction && !handled.current) { + handled.current = true + void fn() + } + }, [action, fn]) +} diff --git a/apps/studio/src/views/project/Project.tsx b/apps/studio/src/views/project/Project.tsx index 1b0d0209..183c17af 100644 --- a/apps/studio/src/views/project/Project.tsx +++ b/apps/studio/src/views/project/Project.tsx @@ -1,5 +1,5 @@ import { useRef } from 'react' -import { useWhenAction } from '../../containers/StudioContainer' +import { useWhenAction } from '../../studio/util/UseWhenAction' import { useProjectPageContext } from '../../contexts/ProjectPageContext' import StudioGridRaw from '../../studio/griddividers/components/StudioGrid' import StudioGridArea from '../../studio/griddividers/components/StudioGridArea' diff --git a/apps/studio/src/views/project/components/ProjectRemote.tsx b/apps/studio/src/views/project/components/ProjectRemote.tsx index 10db5843..f6129140 100644 --- a/apps/studio/src/views/project/components/ProjectRemote.tsx +++ b/apps/studio/src/views/project/components/ProjectRemote.tsx @@ -5,7 +5,7 @@ import { MouseEvent, RefObject, useCallback, useEffect, useMemo, useRef, useStat import GithubAccountButton from "../../../components/GithubAccountButton"; import { MinimizeButton } from "../../../components/MinimizeButton"; import { ButtonWithTooltip } from "../../../components/Tooltips"; -import { useWhenAction } from "../../../containers/StudioContainer"; +import { useWhenAction } from "../../../studio/util/UseWhenAction"; import { useProjectPageContext } from "../../../contexts/ProjectPageContext"; import { useStudio } from "../../../contexts/StudioContext"; import { useDialogBoxes } from "../../../dialogboxes/DialogBoxes"; diff --git a/mcp.dumbcode-studio.example.json b/mcp.dumbcode-studio.example.json new file mode 100644 index 00000000..3e0cdf83 --- /dev/null +++ b/mcp.dumbcode-studio.example.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "dumbcode-studio": { + "command": "node", + "args": ["/absolute/path/to/DumbCode-Studio/apps/mcp-server/dist/index.js"], + "env": { + "DCS_STUDIO_URL": "http://localhost:3000" + } + } + } +} diff --git a/package.json b/package.json index 34c3ee87..6109f051 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "test": "turbo run test", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "prepare": "husky install", - "precommit": "lint-staged" + "precommit": "lint-staged", + "mcp:build": "yarn workspace dumbcode-studio-mcp build", + "mcp:start": "yarn workspace dumbcode-studio-mcp start", + "mcp:dev": "yarn workspace dumbcode-studio-mcp dev" }, "devDependencies": { "@dumbcode/eslint-config-custom": "*", @@ -25,5 +28,9 @@ "engines": { "node": ">=14.0.0" }, - "packageManager": "yarn@1.22.15" + "packageManager": "yarn@1.22.15", + "resolutions": { + "@types/react": "18.0.17", + "@types/react-dom": "18.0.6" + } } diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index bed58161..d5d0b4af 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -4,5 +4,7 @@ module.exports = { "@next/next/no-html-link-for-pages": "off", "react/jsx-key": "off", "react/no-unknown-property": "off", + + "turbo/no-undeclared-env-vars": "off", }, }; \ No newline at end of file diff --git a/packages/mcp-contract/index.d.ts b/packages/mcp-contract/index.d.ts new file mode 100644 index 00000000..af8ffc87 --- /dev/null +++ b/packages/mcp-contract/index.d.ts @@ -0,0 +1,44 @@ +export declare const MCP_ACTIONS: readonly [ + "ping", + "get_state", + "get_action_schema", + "set_active_tab", + "new_project", + "list_projects", + "select_project", + "rename_project", + "close_project", + "set_model_metadata", + "set_texture_size", + "list_cubes", + "select_cubes", + "create_cube", + "update_cube", + "set_cube_shape", + "paint_cube_texture", + "delete_cube", + "reparent_cube", + "duplicate_cube", + "run_model_command", + "run_animator_command", + "list_animations", + "create_animation", + "select_animation", + "update_animation", + "delete_animation", + "list_keyframes", + "create_keyframe", + "update_keyframe", + "delete_keyframe", + "set_keyframe_transform", + "list_keyframe_layers", + "create_keyframe_layer", + "update_keyframe_layer", + "delete_keyframe_layer", + "set_temporary_parent", + "export_asset", + "import_asset" +] +export type McpActionName = typeof MCP_ACTIONS[number] +export declare const MCP_ACTION_DESCRIPTIONS: Record +export declare const MCP_ACTION_SCHEMA: Record> diff --git a/packages/mcp-contract/index.js b/packages/mcp-contract/index.js new file mode 100644 index 00000000..1a776ec7 --- /dev/null +++ b/packages/mcp-contract/index.js @@ -0,0 +1,125 @@ +export const MCP_ACTIONS = [ + "ping", + "get_state", + "get_action_schema", + "set_active_tab", + "new_project", + "list_projects", + "select_project", + "rename_project", + "close_project", + "set_model_metadata", + "set_texture_size", + "list_cubes", + "select_cubes", + "create_cube", + "update_cube", + "set_cube_shape", + "paint_cube_texture", + "delete_cube", + "reparent_cube", + "duplicate_cube", + "run_model_command", + "run_animator_command", + "list_animations", + "create_animation", + "select_animation", + "update_animation", + "delete_animation", + "list_keyframes", + "create_keyframe", + "update_keyframe", + "delete_keyframe", + "set_keyframe_transform", + "list_keyframe_layers", + "create_keyframe_layer", + "update_keyframe_layer", + "delete_keyframe_layer", + "set_temporary_parent", + "export_asset", + "import_asset", +] + +export const MCP_ACTION_DESCRIPTIONS = { + ping: "Verify that the Studio browser bridge is online.", + get_state: "Return complete high-level state for projects, model cubes, animations, keyframes, layers, selection, active tab, and undo/redo status.", + get_action_schema: "Return the REST/MCP action catalog and argument contract.", + set_active_tab: "Switch the visible Studio tab by name: Project, Modeler, Mapper, Texturer, Animator, Showcase, or Options.", + new_project: "Create and select a new DumbCode Studio project.", + list_projects: "List open projects.", + select_project: "Select an open project by projectId, identifier, name, or index.", + rename_project: "Rename a project.", + close_project: "Close a project.", + set_model_metadata: "Merge metadata into the selected model.", + set_texture_size: "Set model texture width and height.", + list_cubes: "List cubes in the selected project, including hierarchy and transform data.", + select_cubes: "Select cubes by id, identifier, name, or path.", + create_cube: "Create a cube under the model root, as a sibling of a target cube, or as a child of a target cube.", + update_cube: "Update cube name, dimensions, rotation point, offset, rotation, cube grow, texture offset/mirroring, visibility, lock state, metadata, or parent.", + set_cube_shape: "Update cube dimensions and/or cube grow in one batch (visual shape / hitbox in the modeler).", + paint_cube_texture: "Fill solid RGB(A) color onto this cube's UV rectangles on a raster texture (same atlas layout as the texture mapper preview).", + delete_cube: "Delete cubes, optionally keeping their children by moving those children up to the deleted cube's parent.", + reparent_cube: "Move a cube under another cube or back to the root.", + duplicate_cube: "Clone a cube subtree and add it under a requested parent/root.", + run_model_command: "Run an existing DumbCode Studio model command string.", + run_animator_command: "Run an existing DumbCode Studio animator command string.", + list_animations: "List animations on the selected project.", + create_animation: "Create and select an animation.", + select_animation: "Select an animation by id, identifier, name, or index.", + update_animation: "Update animation name, skeleton/name override flags, current time, playback flag, IK anchor cubes, IK direction, and loop data.", + delete_animation: "Delete an animation from the selected project.", + list_keyframes: "List keyframes on the selected or specified animation.", + create_keyframe: "Create a keyframe with start time, duration, layer, selection flag, progression points, and optional transform maps.", + update_keyframe: "Update keyframe timing, selection, layer, progression points, and transform maps.", + delete_keyframe: "Delete a keyframe.", + set_keyframe_transform: "Set or clear one cube's position, rotation, and cube-grow deltas on a keyframe.", + list_keyframe_layers: "List keyframe layers on an animation.", + create_keyframe_layer: "Create a keyframe layer and set its properties.", + update_keyframe_layer: "Update keyframe layer name, visibility, lock state, and defined mode.", + delete_keyframe_layer: "Delete a keyframe layer and keyframes on that layer.", + set_temporary_parent: "Set or remove animator temporary parenting between cubes.", + export_asset: "Export selected project/model/animation as base64 .dcproj, .dcm, or .dca data.", + import_asset: "Import a base64 .dcproj, .bbmodel, .dcm, .tbl, or .dca asset. Animations import into the selected project.", +} + +export const MCP_ACTION_SCHEMA = { + ping: {}, + get_state: { includeHistory: "boolean optional; include undo/redo summaries", includeCommands: "boolean optional; include command names" }, + get_action_schema: {}, + set_active_tab: { tab: "Project | Modeler | Mapper | Texturer | Animator | Showcase | Options" }, + new_project: { name: "string optional" }, + list_projects: {}, + select_project: { projectId: "project identifier/name/index; optional means selected/current" }, + rename_project: { projectId: "optional project selector", name: "new project name" }, + close_project: { projectId: "optional project selector" }, + set_model_metadata: { projectId: "optional project selector", metadata: "object of string values" }, + set_texture_size: { projectId: "optional project selector", width: "number", height: "number" }, + list_cubes: { projectId: "optional project selector", includeChildren: "boolean optional" }, + select_cubes: { projectId: "optional project selector", cubes: "array of cube ids/names/paths", mode: "replace | add | remove; default replace" }, + create_cube: { projectId: "optional project selector", name: "string optional", parent: "cube selector optional", siblingOf: "cube selector optional", dimension: "[x,y,z] optional", position: "[x,y,z] optional", offset: "[x,y,z] optional", rotation: "[x,y,z] optional", cubeGrow: "[x,y,z] optional", textureOffset: "[u,v] optional", textureMirrored: "boolean optional", hideChildren: "boolean optional", visible: "boolean optional", locked: "boolean optional", metadata: "object optional" }, + update_cube: { projectId: "optional project selector", cube: "cube selector", name: "string optional", dimension: "[x,y,z] optional", position: "[x,y,z] optional", offset: "[x,y,z] optional", rotation: "[x,y,z] optional", cubeGrow: "[x,y,z] optional", textureOffset: "[u,v] optional", textureMirrored: "boolean optional", visible: "boolean optional", locked: "boolean optional", hideChildren: "boolean optional", metadata: "object optional", parent: "cube selector/null optional" }, + set_cube_shape: { projectId: "optional project selector", cube: "cube selector", dimension: "[x,y,z] optional", cubeGrow: "[x,y,z] optional" }, + paint_cube_texture: { projectId: "optional project selector", cube: "cube selector", color: "[r,g,b] or [r,g,b,a] integers 0-255", faces: "all | one face string | array; indices 0-5 = +X,-X,+Y,-Y,+Z,-Z (east,west,up,down,south,north). Many aliases: px/nx/py/ny/pz/nz, right/left/top/bottom/front/back, uv_east...uv_north, face0...face5, pos_x, forward, ...", textureId: "optional texture identifier to paint on; default first texture in selected group or default group" }, + delete_cube: { projectId: "optional project selector", cube: "single cube selector optional if cubes is omitted", cubes: "array of cube selectors optional", keepChildren: "boolean optional" }, + reparent_cube: { projectId: "optional project selector", cube: "cube selector", parent: "cube selector or null for root" }, + duplicate_cube: { projectId: "optional project selector", cube: "cube selector", parent: "cube selector optional/null", name: "string optional" }, + run_model_command: { projectId: "optional project selector", command: "existing modeler command text" }, + run_animator_command: { projectId: "optional project selector", command: "existing animator command text" }, + list_animations: { projectId: "optional project selector" }, + create_animation: { projectId: "optional project selector", name: "string optional", select: "boolean optional", isSkeleton: "boolean optional" }, + select_animation: { projectId: "optional project selector", animation: "animation selector" }, + update_animation: { projectId: "optional project selector", animation: "animation selector optional", name: "string optional", isSkeleton: "boolean optional", nameOverridesOnly: "boolean optional", time: "number optional", playing: "boolean optional", ikAnchorCubes: "array cube selectors optional", ikDirection: "upwards | downwards optional", loopData: "{exists,start,end,duration} optional" }, + delete_animation: { projectId: "optional project selector", animation: "animation selector" }, + list_keyframes: { projectId: "optional project selector", animation: "animation selector optional" }, + create_keyframe: { projectId: "optional project selector", animation: "animation selector optional", layerId: "number optional", startTime: "number optional", duration: "number optional", selected: "boolean optional", progressionPoints: "[{x,y,required?}] optional", position: "record cubeName->[x,y,z] optional", rotation: "record cubeName->[x,y,z] optional", cubeGrow: "record cubeName->[x,y,z] optional" }, + update_keyframe: { projectId: "optional project selector", animation: "animation selector optional", keyframe: "keyframe selector", layerId: "number optional", startTime: "number optional", duration: "number optional", selected: "boolean optional", progressionPoints: "array optional", position: "record optional; null values clear entries", rotation: "record optional; null values clear entries", cubeGrow: "record optional; null values clear entries" }, + delete_keyframe: { projectId: "optional project selector", animation: "animation selector optional", keyframe: "keyframe selector" }, + set_keyframe_transform: { projectId: "optional project selector", animation: "animation selector optional", keyframe: "keyframe selector", cube: "cube selector/name", position: "[x,y,z] or null optional", rotation: "[x,y,z] or null optional", cubeGrow: "[x,y,z] or null optional", useCubeName: "boolean optional; default true" }, + list_keyframe_layers: { projectId: "optional project selector", animation: "animation selector optional" }, + create_keyframe_layer: { projectId: "optional project selector", animation: "animation selector optional", layerId: "number optional", name: "string optional", visible: "boolean optional", locked: "boolean optional", definedMode: "boolean optional" }, + update_keyframe_layer: { projectId: "optional project selector", animation: "animation selector optional", layerId: "number", name: "string optional", visible: "boolean optional", locked: "boolean optional", definedMode: "boolean optional" }, + delete_keyframe_layer: { projectId: "optional project selector", animation: "animation selector optional", layerId: "number" }, + set_temporary_parent: { projectId: "optional project selector", animation: "animation selector optional", cube: "cube selector", parent: "cube selector or null" }, + export_asset: { projectId: "optional project selector", format: "dcproj | dcm | dca", animation: "animation selector required for dca unless selected animation exists" }, + import_asset: { filename: "string", base64: "base64 file contents", mimeType: "string optional", projectId: "optional project selector for .dca imports", select: "boolean optional" }, +} diff --git a/packages/mcp-contract/package.json b/packages/mcp-contract/package.json new file mode 100644 index 00000000..a52eed05 --- /dev/null +++ b/packages/mcp-contract/package.json @@ -0,0 +1,8 @@ +{ + "name": "@dumbcode/mcp-contract", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./index.js", + "types": "./index.d.ts" +} diff --git a/turbo.json b/turbo.json index 245c6ad2..23d5eea6 100644 --- a/turbo.json +++ b/turbo.json @@ -2,24 +2,19 @@ "$schema": "https://turborepo.org/schema.json", "tasks": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - "dist/**", - ".next/**" - ] + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**"] }, "studio#build": { "dependsOn": [ - "^build", - "$NEXT_PUBLIC_IMGUR_TOKEN", - "$DOCS_URL", - "$GITHUB_CLIENT_ID", - "$GITHUB_CLIENT_SECRET", - "$VERCEL_GIT_COMMIT_SHA", - "$VERCEL_GIT_COMMIT_MESSAGE", - "$VERCEL_GIT_COMMIT_AUTHOR_NAME" + "^build" + //"$NEXT_PUBLIC_IMGUR_TOKEN", + //"$DOCS_URL", + //"$GITHUB_CLIENT_ID", + //"$GITHUB_CLIENT_SECRET", + //"$VERCEL_GIT_COMMIT_SHA", + //"$VERCEL_GIT_COMMIT_MESSAGE", + //"$VERCEL_GIT_COMMIT_AUTHOR_NAME" ] }, "lint": { @@ -32,4 +27,4 @@ "cache": false } } -} \ No newline at end of file +}