How to design and register a new MCP tool — input schema (Zod), output contract, descriptions, tests. Read before adding any new tool to Tools/src/tools/.
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
How to design and register a new MCP tool — input schema (Zod), output contract, descriptions, tests. Read before adding any new tool to Tools/src/tools/.
MCP tool schema
Checklist for adding a new tool. Read end-to-end before writing the tool file.
Checklist
Tool belongs in a group file under Tools/src/tools/<group>.ts. Pick existing if domain matches; create new if it doesn't.
Tool name follows <domain>_<verb>[<_qualifier>]: object_create, material_set_principled_param, bone_add, socket_add, export_fbx.
Zod schema for every input parameter, with .describe() on each.
Return type is Promise<ToolResult<T>> where T is the success payload shape.
On success: populate data, refs, optionally nextSteps, optionally warnings.
On failure: ok: false, errorCode from the registry, warnings with detail.
Description string written for the LLM consuming the tool — under 200 chars for simple, structured multi-line for complex (especially exports).
Integration test in Tools/test/tools/<tool-name>.test.ts covering happy path, every errorCode branch, idempotency where applicable.
Python handler exists if needed, registered via @handler("METHOD", "/path"), follows the handler pattern from .claude/rules/python-blender.md.
// Tools/src/tools/my-domain.tsimport { z } from"zod";
importtype { McpServer } from"@modelcontextprotocol/sdk/server/mcp.js";
import { blenderPost } from"../blender-bridge.js";
importtype { ToolResult } from"../types.js";
exportfunctionregisterMyDomainTools(server: McpServer): void {
server.tool(
"my_domain_do_thing",
"Short imperative description aimed at the LLM. Under 200 chars. What it does, what it returns, when to call it next.",
{
objectName: z.string().describe("Object name (data-block key in bpy.data.objects)"),
// ...
},
async (input): Promise<ToolResult<{ /* payload */ }>> => {
try {
const res = await blenderPost<{ /* python response */ }>("/my_domain/do_thing", input);
return {
ok: true,
data: { /* mapped */ },
refs: { /* IDs */ },
nextSteps: ["call ... next"],
};
} catch (err) {
// narrow to known errorCodes when possiblereturn { ok: false, errorCode: "BLENDER_HTTP_FAILED", warnings: [String(err)] };
}
}
);
}
Then add registerMyDomainTools(server) to Tools/src/index.ts.
Python handler template
# BlenderAgent/handlers/my_domain.pyimport bpy
from ..server import handler, run_on_main, HandlerError, with_mode
@handler("POST", "/my_domain/do_thing")defdo_thing(req):
object_name = req["object_name"] # snake_case on the wire# ... pull other paramsdefmain():
obj = bpy.data.objects.get(object_name)
if obj isNone:
raise HandlerError("OBJECT_NOT_FOUND", f"object {object_name!r} not found")
# ... mutation
bpy.ops.ed.undo_push(message="Do thing on object")
return {"object_name": obj.name}
return run_on_main(main)
Then ensure the module is imported in BlenderAgent/handlers/__init__.py so the @handler decorator runs.
Description writing — for the LLM
The description is the agent's only hint at when to call the tool. Write it for that.
✅ "Add a bone to an armature. Switches to Edit Mode automatically and restores afterwards. Returns the bone name. Call bone_set_transform or socket_add next."
❌ "Adds a bone" (too terse — agent guesses inputs).
❌ "Switches active object to the armature, enters Edit Mode via bpy.ops.object.mode_set, calls armature.data.edit_bones.new with name parameter, sets head/tail from request payload..." (noise; wastes tokens).
For complex tools (export_* especially): multi-section structured description quoting the operator name and Blender's tooltip per parameter group.
Tests are mandatory
Every new tool → Tools/test/tools/<tool-name>.test.ts.