#!/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 { CanvasBridge } from "./bridge.js";
import { GeoShape, Color, Fill, Size, Align } from "./schema.js";
const bridge = new CanvasBridge(process.env.TLDRAW_WS_URL || "ws://localhost:4000");
const server = new McpServer({
name: "tldraw-mcp",
version: "0.1.0",
});
// ─────────────────────────────────────────────────────────────────────────────
// Tools
// ─────────────────────────────────────────────────────────────────────────────
server.tool(
"create_shape",
"Create a shape on the canvas (rectangle, ellipse, text, note, etc.)",
{
type: z.enum(["geo", "text", "note"]).describe("Shape type"),
x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
width: z.number().optional().default(200).describe("Width"),
height: z.number().optional().default(200).describe("Height"),
text: z.string().optional().describe("Text content"),
geo: GeoShape.optional().default("rectangle").describe("Geo shape type"),
color: Color.optional().default("black").describe("Color"),
fill: Fill.optional().default("none").describe("Fill style"),
},
async (args) => {
try {
const result = await bridge.createShape({
type: args.type,
x: args.x,
y: args.y,
width: args.width,
height: args.height,
text: args.text,
geo: args.geo,
color: args.color,
fill: args.fill,
});
return { content: [{ type: "text", text: `Created shape: ${result.id}` }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"update_shape",
"Update properties of an existing shape",
{
id: z.string().describe("Shape ID"),
x: z.number().optional().describe("New X coordinate"),
y: z.number().optional().describe("New Y coordinate"),
width: z.number().optional().describe("New width"),
height: z.number().optional().describe("New height"),
text: z.string().optional().describe("New text content"),
color: Color.optional().describe("New color"),
fill: Fill.optional().describe("New fill style"),
},
async (args) => {
try {
const { id, ...props } = args;
await bridge.updateShape(id, props);
return { content: [{ type: "text", text: `Updated shape: ${id}` }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"delete_shapes",
"Delete one or more shapes from the canvas",
{
ids: z.array(z.string()).describe("Shape IDs to delete"),
},
async (args) => {
try {
await bridge.deleteShapes(args.ids);
return { content: [{ type: "text", text: `Deleted ${args.ids.length} shape(s)` }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"connect_shapes",
"Connect two shapes with an arrow",
{
from: z.string().describe("Source shape ID"),
to: z.string().describe("Target shape ID"),
label: z.string().optional().describe("Arrow label"),
},
async (args) => {
try {
const result = await bridge.connectShapes(args.from, args.to, args.label);
return { content: [{ type: "text", text: `Connected shapes: ${result.id}` }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"get_snapshot",
"Get current state of all shapes on the canvas",
{},
async () => {
try {
const state = await bridge.getSnapshot();
const summary = state.shapes
.map((s) => `- ${s.id}: ${s.type}${s.geo ? ` (${s.geo})` : ""} at (${s.x}, ${s.y})`)
.join("\n");
return {
content: [
{
type: "text",
text: `Canvas has ${state.shapes.length} shapes:\n${summary || "(empty)"}`,
},
],
};
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"clear_canvas",
"Remove all shapes from the canvas",
{},
async () => {
try {
await bridge.clear();
return { content: [{ type: "text", text: "Canvas cleared" }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"zoom_to_fit",
"Zoom the canvas to fit all shapes in view",
{},
async () => {
try {
await bridge.zoomToFit();
return { content: [{ type: "text", text: "Zoomed to fit all shapes" }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
server.tool(
"create_frame",
"Create a frame to group shapes together",
{
x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
width: z.number().optional().default(400).describe("Width"),
height: z.number().optional().default(300).describe("Height"),
name: z.string().optional().describe("Frame label"),
},
async (args) => {
try {
const result = await bridge.createFrame(
args.x,
args.y,
args.width,
args.height,
args.name
);
return { content: [{ type: "text", text: `Created frame: ${result.id}` }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
const FlowchartNode = z.object({
id: z.string().describe("Unique node ID (for connections)"),
label: z.string().optional().describe("Node label"),
geo: GeoShape.optional().default("rectangle").describe("Shape type"),
color: Color.optional().default("black").describe("Color"),
fill: Fill.optional().default("semi").describe("Fill style"),
});
const FlowchartEdge = z.object({
from: z.string().describe("Source node ID"),
to: z.string().describe("Target node ID"),
label: z.string().optional().describe("Edge label"),
});
server.tool(
"create_flowchart",
"Create a flowchart diagram with nodes and connections. Nodes are auto-arranged.",
{
nodes: z.array(FlowchartNode).describe("List of nodes"),
edges: z.array(FlowchartEdge).optional().describe("List of connections between nodes"),
direction: z.enum(["horizontal", "vertical"]).optional().default("vertical").describe("Layout direction"),
},
async (args) => {
try {
await bridge.clear();
const nodeWidth = 160;
const nodeHeight = 80;
const gapX = 80;
const gapY = 100;
// Calculate positions based on direction
const nodeMap = new Map<string, string>(); // user ID -> shape ID
const createdShapes: string[] = [];
for (let i = 0; i < args.nodes.length; i++) {
const node = args.nodes[i];
const x = args.direction === "horizontal"
? 100 + i * (nodeWidth + gapX)
: 100 + (i % 3) * (nodeWidth + gapX);
const y = args.direction === "horizontal"
? 100 + (i % 2) * (nodeHeight + gapY)
: 100 + Math.floor(i / 3) * (nodeHeight + gapY);
const result = await bridge.createShape({
type: "geo",
x,
y,
width: nodeWidth,
height: nodeHeight,
geo: node.geo || "rectangle",
color: node.color || "black",
fill: node.fill || "semi",
});
nodeMap.set(node.id, result.id);
createdShapes.push(result.id);
}
// Create edges
const createdEdges: string[] = [];
if (args.edges) {
for (const edge of args.edges) {
const fromId = nodeMap.get(edge.from);
const toId = nodeMap.get(edge.to);
if (fromId && toId) {
const result = await bridge.connectShapes(fromId, toId, edge.label);
createdEdges.push(result.id);
}
}
}
return {
content: [{
type: "text",
text: `Created flowchart with ${createdShapes.length} nodes and ${createdEdges.length} edges`,
}],
};
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err}` }], isError: true };
}
}
);
// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("tldraw-mcp server running on stdio");
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});