touchdesigner-mcp-server
Version:
MCP server for TouchDesigner
371 lines (357 loc) • 16.8 kB
JavaScript
import { z } from "zod";
import { REFERENCE_COMMENT, TOOL_NAMES } from "../../core/constants.js";
import { CreateNodeBody, DeleteNodeQueryParams, ExecNodeMethodBody, ExecPythonScriptBody, GetModuleHelpQueryParams, GetNodeDetailQueryParams, GetNodeErrorsQueryParams, GetNodesQueryParams, GetTdPythonClassDetailsParams, UpdateNodeBody, } from "../../gen/mcp/touchDesignerAPI.zod.js";
import { formatClassDetails, formatClassList, formatCreateNodeResult, formatDeleteNodeResult, formatExecNodeMethodResult, formatModuleHelp, formatNodeDetails, formatNodeErrors, formatNodeList, formatScriptResult, formatTdInfo, formatUpdateNodeResult, } from "./presenter/index.js";
import { buildGetTopImageScript } from "./pythonScripts/getTopImageScript.js";
import { detailOnlyFormattingSchema, formattingOptionsSchema, } from "./types.js";
/**
* Authoring helper that infers `params` from the tool's schema while erasing to
* the uniform {@link ToolDefinition} shape for storage in the table.
*/
function defineTool(def) {
// `run` has a narrower param type (z.infer<S>) than ToolDefinition exposes
// (Record<string, unknown>). Function parameters are contravariant, so a
// direct `as ToolDefinition` is rejected; the double cast is required. Safe
// because params are validated by the MCP SDK before reaching run().
return def;
}
/**
* `get_top_image` has no OpenAPI-backed endpoint (see architecture note on
* the tool definition below), so its params schema is defined locally
* instead of being derived from the generated Zod schemas.
*/
const GetTopImageParams = z.object({
maxSize: z
.number()
.int()
.positive()
.max(4096)
.optional()
.describe("Maximum length of the image's longer side in pixels. The TOP is downscaled (aspect ratio preserved) only if it exceeds this value; omit to capture at native resolution."),
nodePath: z
.string()
.min(1)
.describe("Path to the TOP node to capture, e.g. '/project1/moviefilein1'"),
});
export const TOOL_DEFINITIONS = [
defineTool({
category: "system",
description: "Get server information from TouchDesigner",
example: `import { getTdInfo } from './servers/touchdesigner/getTdInfo';
const info = await getTdInfo();
console.log(\`\${info.server} \${info.version}\`);`,
name: TOOL_NAMES.GET_TD_INFO,
returns: "TouchDesigner build metadata (server, version, operating system).",
run: async ({ params, tdClient }) => {
const result = await tdClient.getTdInfo();
if (!result.success) {
throw result.error;
}
return formatTdInfo(result.data, {
detailLevel: params.detailLevel ?? "summary",
responseFormat: params.responseFormat,
});
},
schema: detailOnlyFormattingSchema,
}),
defineTool({
category: "python",
description: "Execute a Python script in TouchDesigner (detailLevel=minimal|summary|detailed, responseFormat=json|yaml|markdown)",
example: `import { executePythonScript } from './servers/touchdesigner/executePythonScript';
await executePythonScript({
script: "op('/project1/text1').par.text = 'Hello MCP'",
});`,
name: TOOL_NAMES.EXECUTE_PYTHON_SCRIPT,
notes: "Wrap long-running scripts with logging so the agent can stream intermediate checkpoints.",
returns: "Result payload that mirrors `result` from the executed script (if set).",
run: async ({ params, tdClient, logger }) => {
const { detailLevel, responseFormat, ...scriptParams } = params;
logger.sendLog({
data: `Executing script: ${scriptParams.script}`,
level: "debug",
});
const result = await tdClient.execPythonScript(scriptParams);
if (!result.success) {
throw result.error;
}
return formatScriptResult(result, scriptParams.script, {
detailLevel: detailLevel ?? "summary",
responseFormat,
});
},
schema: ExecPythonScriptBody.extend(detailOnlyFormattingSchema.shape),
}),
defineTool({
category: "nodes",
description: "List nodes under a path with token-optimized output (detailLevel+limit supported)",
errorComment: REFERENCE_COMMENT,
example: `import { getTdNodes } from './servers/touchdesigner/getTdNodes';
const nodes = await getTdNodes({
parentPath: '/project1',
pattern: 'geo*',
});
console.log(nodes.nodes?.map(node => node.path));`,
name: TOOL_NAMES.GET_TD_NODES,
returns: "Set of nodes (id, opType, name, path, optional properties) under parentPath.",
run: async ({ params, tdClient }) => {
const { detailLevel, limit, responseFormat, ...queryParams } = params;
const result = await tdClient.getNodes(queryParams);
if (!result.success) {
throw result.error;
}
const fallbackMode = queryParams.includeProperties
? "detailed"
: "summary";
return formatNodeList(result.data, {
detailLevel: detailLevel ?? fallbackMode,
limit,
responseFormat,
});
},
schema: GetNodesQueryParams.extend(formattingOptionsSchema.shape),
}),
defineTool({
category: "nodes",
description: "Get node parameters with concise/detailed formatting (detailLevel+limit supported)",
errorComment: REFERENCE_COMMENT,
example: `import { getTdNodeParameters } from './servers/touchdesigner/getTdNodeParameters';
const node = await getTdNodeParameters({ nodePath: '/project1/text1' });
console.log(node.properties?.Text);`,
name: TOOL_NAMES.GET_TD_NODE_PARAMETERS,
returns: "Full node record with parameters, paths, and metadata.",
run: async ({ params, tdClient }) => {
const { detailLevel, limit, responseFormat, ...queryParams } = params;
const result = await tdClient.getNodeDetail(queryParams);
if (!result.success) {
throw result.error;
}
return formatNodeDetails(result.data, {
detailLevel: detailLevel ?? "summary",
limit,
responseFormat,
});
},
schema: GetNodeDetailQueryParams.extend(formattingOptionsSchema.shape),
}),
defineTool({
category: "nodes",
description: "Check node and descendant errors reported by TouchDesigner",
errorComment: REFERENCE_COMMENT,
example: `import { getTdNodeErrors } from './servers/touchdesigner/getTdNodeErrors';
const report = await getTdNodeErrors({
nodePath: '/project1/text1',
});
if (report.hasErrors) {
console.log(report.errors?.map(err => err.message));
}`,
name: TOOL_NAMES.GET_TD_NODE_ERRORS,
returns: "Error report outlining offending nodes, messages, and counts.",
run: async ({ params, tdClient }) => {
const { detailLevel, limit, responseFormat, ...queryParams } = params;
const result = await tdClient.getNodeErrors(queryParams);
if (!result.success) {
throw result.error;
}
return formatNodeErrors(result.data, {
detailLevel: detailLevel ?? "summary",
limit,
responseFormat,
});
},
schema: GetNodeErrorsQueryParams.extend(formattingOptionsSchema.shape),
}),
defineTool({
category: "nodes",
description: "Create a new node in TouchDesigner",
errorComment: REFERENCE_COMMENT,
example: `import { createTdNode } from './servers/touchdesigner/createTdNode';
const created = await createTdNode({
parentPath: '/project1',
nodeType: 'textTOP',
nodeName: 'title',
});
console.log(created.result?.path);`,
name: TOOL_NAMES.CREATE_TD_NODE,
returns: "Created node metadata including resolved path and properties.",
run: async ({ params, tdClient }) => {
const { detailLevel, responseFormat, ...createParams } = params;
const result = await tdClient.createNode(createParams);
if (!result.success) {
throw result.error;
}
return formatCreateNodeResult(result.data, {
detailLevel: detailLevel ?? "summary",
responseFormat,
});
},
schema: CreateNodeBody.extend(detailOnlyFormattingSchema.shape),
}),
defineTool({
category: "nodes",
description: "Update parameters of a specific node in TouchDesigner",
errorComment: REFERENCE_COMMENT,
example: `import { updateTdNodeParameters } from './servers/touchdesigner/updateTdNodeParameters';
await updateTdNodeParameters({
nodePath: '/project1/text1',
properties: { text: 'Hello TouchDesigner' },
});`,
name: TOOL_NAMES.UPDATE_TD_NODE_PARAMETERS,
returns: "Lists of updated vs failed parameters so the agent can retry selectively.",
run: async ({ params, tdClient }) => {
const { detailLevel, responseFormat, ...updateParams } = params;
const result = await tdClient.updateNode(updateParams);
if (!result.success) {
throw result.error;
}
return formatUpdateNodeResult(result.data, {
detailLevel: detailLevel ?? "summary",
responseFormat,
});
},
schema: UpdateNodeBody.extend(detailOnlyFormattingSchema.shape),
}),
defineTool({
category: "nodes",
description: "Delete an existing node in TouchDesigner",
errorComment: REFERENCE_COMMENT,
example: `import { deleteTdNode } from './servers/touchdesigner/deleteTdNode';
const result = await deleteTdNode({ nodePath: '/project1/tmp1' });
console.log(result.deleted);`,
name: TOOL_NAMES.DELETE_TD_NODE,
returns: "Deletion status plus previous node metadata when available.",
run: async ({ params, tdClient }) => {
const { detailLevel, responseFormat, ...deleteParams } = params;
const result = await tdClient.deleteNode(deleteParams);
if (!result.success) {
throw result.error;
}
return formatDeleteNodeResult(result.data, {
detailLevel: detailLevel ?? "summary",
responseFormat,
});
},
schema: DeleteNodeQueryParams.extend(detailOnlyFormattingSchema.shape),
}),
defineTool({
category: "nodes",
description: "Execute a method on a specific node in TouchDesigner",
errorComment: REFERENCE_COMMENT,
example: `import { execNodeMethod } from './servers/touchdesigner/execNodeMethod';
const renderStatus = await execNodeMethod({
nodePath: '/project1/render1',
method: 'par',
kwargs: { enable: true },
});
console.log(renderStatus.result);`,
name: TOOL_NAMES.EXECUTE_NODE_METHOD,
returns: "Raw method return payload including any serializable values.",
run: async ({ params, tdClient }) => {
const { detailLevel, responseFormat, ...execParams } = params;
const { nodePath, method, args, kwargs } = execParams;
const result = await tdClient.execNodeMethod(execParams);
if (!result.success) {
throw result.error;
}
return formatExecNodeMethodResult(result.data, { args, kwargs, method, nodePath }, { detailLevel: detailLevel ?? "summary", responseFormat });
},
schema: ExecNodeMethodBody.extend(detailOnlyFormattingSchema.shape),
}),
defineTool({
category: "classes",
description: "List TouchDesigner Python classes/modules (detailLevel+limit supported)",
errorComment: REFERENCE_COMMENT,
example: `import { getTdClasses } from './servers/touchdesigner/getTdClasses';
const classes = await getTdClasses({ limit: 20 });
console.log(classes.classes?.map(cls => cls.name));`,
name: TOOL_NAMES.GET_TD_CLASSES,
returns: "Python class catalogue with names, types, and optional summaries.",
run: async ({ params, tdClient }) => {
const result = await tdClient.getClasses();
if (!result.success) {
throw result.error;
}
return formatClassList(result.data, {
detailLevel: params.detailLevel ?? "summary",
limit: params.limit ?? 50,
responseFormat: params.responseFormat,
});
},
schema: formattingOptionsSchema,
}),
defineTool({
category: "classes",
description: "Get information about a TouchDesigner class/module (detailLevel+limit supported)",
errorComment: REFERENCE_COMMENT,
example: `import { getTdClassDetails } from './servers/touchdesigner/getTdClassDetails';
const textTop = await getTdClassDetails({ className: 'textTOP' });
console.log(textTop.methods?.length);`,
name: TOOL_NAMES.GET_TD_CLASS_DETAILS,
returns: "Deep description of a Python class including methods and properties.",
run: async ({ params, tdClient }) => {
const { className, detailLevel, limit, responseFormat } = params;
const result = await tdClient.getClassDetails(className);
if (!result.success) {
throw result.error;
}
return formatClassDetails(result.data, {
detailLevel: detailLevel ?? "summary",
limit: limit ?? 30,
responseFormat,
});
},
schema: GetTdPythonClassDetailsParams.extend(formattingOptionsSchema.shape),
}),
defineTool({
category: "classes",
description: "Retrieve Python help() text for a TouchDesigner module or class",
example: `import { getTdModuleHelp } from './servers/touchdesigner/getTdModuleHelp';
const docs = await getTdModuleHelp({ moduleName: 'noiseCHOP' });
console.log(docs.helpText?.slice(0, 200));`,
name: TOOL_NAMES.GET_TD_MODULE_HELP,
returns: "Captured Python help() output with formatter context.",
run: async ({ params, tdClient }) => {
const { detailLevel, moduleName, responseFormat } = params;
const result = await tdClient.getModuleHelp({ moduleName });
if (!result.success) {
throw result.error;
}
return formatModuleHelp(result.data, {
detailLevel: detailLevel ?? "summary",
responseFormat,
});
},
schema: GetModuleHelpQueryParams.extend(detailOnlyFormattingSchema.shape),
}),
defineTool({
category: "nodes",
description: "Capture the current output of a TOP node as an image so it can be viewed directly (maxSize optionally downscales the longer side)",
errorComment: REFERENCE_COMMENT,
example: `import { getTopImage } from './servers/touchdesigner/getTopImage';
const image = await getTopImage({ nodePath: '/project1/moviefilein1', maxSize: 512 });`,
name: TOOL_NAMES.GET_TOP_IMAGE,
notes: "Routed through the same Python execution channel as execute_python_script; no dedicated API endpoint exists. When maxSize forces a downscale, a temporary resolutionTOP is created next to the node and always destroyed afterward, so the project is left unmodified.",
returns: "An image content block (base64-encoded JPEG) with the captured TOP output.",
run: async ({ params, tdClient }) => {
const { nodePath, maxSize } = params;
const script = buildGetTopImageScript({ maxSize, nodePath });
const result = await tdClient.execPythonScript({ script });
if (!result.success) {
throw result.error;
}
const base64Data = result.data.result;
if (typeof base64Data !== "string" || base64Data.length === 0) {
throw new Error(`get_top_image: expected a base64 string result for ${nodePath}, got ${typeof base64Data}`);
}
return {
content: [
{ data: base64Data, mimeType: "image/jpeg", type: "image" },
{
text: `Captured TOP image from ${nodePath}${maxSize ? ` (maxSize=${maxSize}px)` : ""}.`,
type: "text",
},
],
};
},
schema: GetTopImageParams,
}),
];