#
tokens: 11669/50000 7/7 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .gitignore
├── build
│   ├── index.js
│   ├── response.js
│   └── utils.js
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── index.ts
│   ├── response.ts
│   └── utils.ts
├── thumbnail.png
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# vuepress v2.x temp and cache directory
.temp
.cache

# vitepress build output
**/.vitepress/dist

# vitepress cache directory
**/.vitepress/cache

# Docusaurus cache and generated files
.docusaurus

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
[![smithery badge](https://smithery.ai/badge/@niklauslee/frame0-mcp-server)](https://smithery.ai/server/@niklauslee/frame0-mcp-server)

[![Frame0 MCP Video Example](https://github.com/niklauslee/frame0-mcp-server/raw/main/thumbnail.png)](https://frame0.app/videos/frame0-mcp-example.mp4)

# Frame0 MCP Server

[Frame0](https://frame0.app/) is a Balsamiq-alternative wireframe tool for modern apps. **Frame0 MCP Server** allows you for creating and modifying wireframes in Frame0 by prompting.

## Setup

Prerequisite:
- [Frame0](https://frame0.app/) `v1.0.0-beta.17` or higher.
- [Node.js](https://nodejs.org/) `v22` or higher.

Setup for Claude Desktop in `claude_desktop_config.json` as below:

```json
{
  "mcpServers": {
    "frame0-mcp-server": {
      "command": "npx",
      "args": ["-y", "frame0-mcp-server"]
    }
  }
}
```

You can use `--api-port=<port>` optional parameter to use another port number for Frame0's API server.

## Example Prompts

- _“Create a login screen for Phone in Frame0”_
- _“Create a Instagram home screen for Phone in Frame0”_
- _“Create a Netflix home screen for TV in Frame0”_
- _“Change the color of the Login button”_
- _“Remove the Twitter social login”_
- _“Replace the emojis by icons”_
- _“Set a link from the google login button to the Google website”_

## Tools

- `create_frame`
- `create_rectangle`
- `create_ellipse`
- `create_text`
- `create_line`
- `create_polygon`
- `create_connector`
- `create_icon`
- `create_image`
- `update_shape`
- `duplicate_shape`
- `delete_shape`
- `search_icons`
- `move_shape`
- `align_shapes`
- `group`
- `ungroup`
- `set_link`
- `export_shape_as_image`
- `add_page`
- `update_page`
- `duplicate_page`
- `delete_page`
- `get_current_page_id`
- `set_current_page_by_id`
- `get_page`
- `get_all_pages`
- `export_page_as_image`

## Dev

1. Clone this repository.
2. Build with `npm run build`.
3. Update `claude_desktop_config.json` in Claude Desktop as below.
4. Restart Claude Desktop.

```json
{
  "mcpServers": {
    "frame0-mcp-server": {
      "command": "node",
      "args": ["<full-path-to>/frame0-mcp-server/build/index.js"]
    }
  }
}
```

```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "frame0-mcp-server",
  "version": "0.11.5",
  "type": "module",
  "description": "",
  "bin": {
    "frame0-mcp-server": "build/index.js"
  },
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "inspect": "npx @modelcontextprotocol/inspector node build/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "files": [
    "build"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/niklauslee/frame0-mcp-server.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/niklauslee/frame0-mcp-server/issues"
  },
  "homepage": "https://github.com/niklauslee/frame0-mcp-server#readme",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.12.1",
    "node-fetch": "^3.3.2",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@types/node": "^22.14.0",
    "prettier": "^3.5.3",
    "tsx": "^4.19.3",
    "typescript": "^5.8.2"
  },
  "packageManager": "[email protected]+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538"
}

```

--------------------------------------------------------------------------------
/src/response.ts:
--------------------------------------------------------------------------------

```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

// Standard JSON-RPC Error Codes
export enum JsonRpcErrorCode {
  ParseError = -32700,
  InvalidRequest = -32600,
  MethodNotFound = -32601,
  InvalidParams = -32602,
  InternalError = -32603,
  // -32000 to -32099 are reserved for implementation-defined server-errors.
  ServerError = -32000,
}

export interface JsonRpcError {
  code: number; // JsonRpcErrorCode or a custom server error code
  message: string;
  data?: unknown;
}

type MimeType = "image/png" | "image/jpeg" | "image/webp" | "image/svg+xml";

export function text(text: string): CallToolResult {
  return {
    content: [
      {
        type: "text",
        text,
      },
    ],
  };
}

export function error(code: number, message: string, data?: unknown): CallToolResult {
  return {
    isError: true,
    error: {
      code,
      message,
      data,
    } as JsonRpcError,
    content: [
      {
        type: "text", // Provide a textual representation of the error in content
        text: message, 
      }
    ]
  };
}

export function image(mimeType: MimeType, data: string): CallToolResult {
  return {
    content: [
      {
        type: "image",
        data,
        mimeType,
      },
    ],
  };
}

```

--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------

```typescript
import fetch from "node-fetch";

const URL = "http://localhost";

export const ARROWHEADS = [
  "none",
  "arrow",
  "bar",
  "circle",
  "circle-filled",
  "circle-plus",
  "cross",
  "crowfoot-many",
  "crowfoot-one",
  "crowfoot-one-many",
  "crowfoot-only-one",
  "crowfoot-zero-many",
  "crowfoot-zero-one",
  "diamond",
  "diamond-filled",
  "dot",
  "plus",
  "solid-arrow",
  "square",
  "triangle",
  "triangle-filled",
] as const;

type CommandResponse = {
  success: boolean;
  data?: any;
  error?: string;
};

export async function command(port: number, command: string, args: any = {}) {
  const res = await fetch(`${URL}:${port}/execute_command`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command,
      args,
    }),
  });
  if (!res.ok) {
    throw new Error(
      `Failed to execute command(${command}) with args: ${JSON.stringify(args)}`
    );
  }
  const json = (await res.json()) as CommandResponse;
  if (!json.success) {
    throw new Error(`Command failed: ${json.error}`);
  }
  return json.data;
}

export function filterShape(shape: any, recursive: boolean = false): any {
  const json: any = {
    id: shape.id,
    parentId: shape.parentId,
    type: shape.type,
    name: shape.name,
    left: shape.left,
    top: shape.top,
    width: shape.width,
    height: shape.height,
    fillColor: shape.fillColor,
    strokeColor: shape.strokeColor,
    strokeWidth: shape.strokeWidth,
    fontColor: shape.fontColor,
    fontSize: shape.fontSize,
  };
  if (typeof shape.text !== "undefined") json.text = shape.text; // TODO: convert node to text
  if (typeof shape.wordWrap !== "undefined") json.wordWrap = shape.wordWrap;
  if (typeof shape.corners !== "undefined") json.corners = shape.corners;
  if (typeof shape.horzAlign !== "undefined") json.horzAlign = shape.horzAlign;
  if (typeof shape.vertAlign !== "undefined") json.vertAlign = shape.vertAlign;
  if (typeof shape.path !== "undefined") json.path = shape.path;
  if (typeof shape.referenceId !== "undefined")
    json.linkToPage = shape.referenceId;
  if (recursive && Array.isArray(shape.children)) {
    json.children = shape.children.map((child: any) => {
      return filterShape(child, recursive);
    });
  }
  return json;
}

export function filterPage(page: any): any {
  const json: any = {
    id: page.id,
    name: page.name,
    children: page.children?.map((shape: any) => {
      return filterShape(shape, true);
    }),
  };
  return json;
}

export function convertArrowhead(arrowhead: string): string {
  switch (arrowhead) {
    case "none":
      return "flat"; // "flat" in dgmjs
    default:
      return arrowhead;
  }
}

/**
 * Trim object by removing undefined values.
 */
export function trimObject(obj: any) {
  const result: any = {};
  Object.keys(obj).forEach((key) => {
    if (obj[key] !== undefined) {
      result[key] = obj[key];
    }
  });
  return result;
}

```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
#!/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 * as response from "./response.js";
import { JsonRpcErrorCode } from "./response.js";
import {
  ARROWHEADS,
  convertArrowhead,
  command,
  filterPage,
  filterShape,
  trimObject,
} from "./utils.js";
import packageJson from "../package.json" with { type: "json" };

const NAME = "frame0-mcp-server";
const VERSION = packageJson.version;

// port number for the Frame0's API server (default: 58320)
let apiPort: number = 58320;

// command line argument parsing
const args = process.argv.slice(2);
const apiPortArg = args.find((arg) => arg.startsWith("--api-port="));
if (apiPortArg) {
  const port = apiPortArg.split("=")[1];
  try {
    apiPort = parseInt(port, 10);
    if (isNaN(apiPort) || apiPort < 0 || apiPort > 65535) {
      throw new Error(`Invalid port number: ${port}`);
    }
  } catch (error) {
    console.error(`Invalid port number: ${port}`);
    process.exit(1);
  }
}

// Create an MCP server
const server = new McpServer({
  name: NAME,
  version: VERSION,
});

server.tool(
  "create_frame",
  "Create a frame shape in Frame0. Must add a new page before you create a new frame.",
  {
    frameType: z
      .enum(["phone", "tablet", "desktop", "browser", "watch", "tv"])
      .describe("Type of the frame shape to create."),
    name: z.string().describe("Name of the frame shape."),
    fillColor: z
      .string()
      .optional()
      .default("#ffffff")
      .describe("Background color in hex code of the frame shape."),
  },
  async ({ frameType, name, fillColor }) => {
    const FRAME_NAME = {
      phone: "Phone",
      tablet: "Tablet",
      desktop: "Desktop",
      browser: "Browser",
      watch: "Watch",
      tv: "TV",
    };
    const FRAME_SIZE = {
      phone: { width: 320, height: 690 },
      tablet: { width: 600, height: 800 },
      desktop: { width: 800, height: 600 },
      browser: { width: 800, height: 600 },
      watch: { width: 198, height: 242 },
      tv: { width: 960, height: 570 },
    };
    const FRAME_HEADER_HEIGHT = {
      phone: 0,
      tablet: 0,
      desktop: 32,
      browser: 76,
      watch: 0,
      tv: 0,
    };
    try {
      // frame headers should be consider to calculate actual content area
      const frameHeaderHeight = FRAME_HEADER_HEIGHT[frameType];
      const frameSize = FRAME_SIZE[frameType];
      const frameName = FRAME_NAME[frameType];
      const shapeId = await command(
        apiPort,
        "shape:create-shape-from-library-by-query",
        {
          query: `${frameName}&@Frame`,
          shapeProps: trimObject({
            name,
            left: 0,
            top: -frameHeaderHeight,
            width: frameSize.width,
            height: frameSize.height + frameHeaderHeight,
            fillColor,
          }),
          convertColors: true,
        }
      );
      await command(apiPort, "view:fit-to-screen");
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created frame: " +
          JSON.stringify({
            ...filterShape(data),
            top: -frameHeaderHeight,
            height: frameSize.height + frameHeaderHeight,
          })
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create frame: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_rectangle",
  `Create a rectangle shape in Frame0.`,
  {
    name: z.string().describe("Name of the rectangle shape."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    left: z
      .number()
      .describe(
        "Left position of the rectangle shape in the absolute coordinate system."
      ),
    top: z
      .number()
      .describe(
        "Top position of the rectangle shape in the absolute coordinate system."
      ),
    width: z.number().describe("Width of the rectangle shape."),
    height: z.number().describe("Height of the rectangle shape."),
    fillColor: z
      .string()
      .optional()
      .default("#ffffff")
      .describe("Fill color in hex code of the rectangle shape."),
    strokeColor: z
      .string()
      .optional()
      .default("#000000")
      .describe("Stroke color in hex code of the rectangle shape."),
    corners: z
      .array(z.number())
      .length(4)
      .optional()
      .default([0, 0, 0, 0])
      .describe(
        "Corner radius of the rectangle shape. Must be in the form of [left-top, right-top, right-bottom, left-bottom]."
      ),
  },
  async ({
    name,
    parentId,
    left,
    top,
    width,
    height,
    fillColor,
    strokeColor,
    corners,
  }) => {
    try {
      const shapeId = await command(apiPort, "shape:create-shape", {
        type: "Rectangle",
        shapeProps: trimObject({
          name,
          left,
          top,
          width,
          height,
          fillColor,
          strokeColor,
          corners,
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created rectangle: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create rectangle: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_ellipse",
  `Create an ellipse shape in Frame0.`,
  {
    name: z.string().describe("Name of the ellipse shape."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    left: z
      .number()
      .describe(
        "Left position of the ellipse shape in the absolute coordinate system."
      ),
    top: z
      .number()
      .describe(
        "Top position of the ellipse shape in the absolute coordinate system."
      ),
    width: z.number().describe("Width of the ellipse shape."),
    height: z.number().describe("Height of the ellipse shape."),
    fillColor: z
      .string()
      .optional()
      .default("#ffffff")
      .describe("Fill color in hex code of the ellipse shape."),
    strokeColor: z
      .string()
      .optional()
      .default("#000000")
      .describe("Stroke color in hex code of the ellipse shape."),
  },
  async ({
    name,
    parentId,
    left,
    top,
    width,
    height,
    fillColor,
    strokeColor,
  }) => {
    try {
      const shapeId = await command(apiPort, "shape:create-shape", {
        type: "Ellipse",
        shapeProps: trimObject({
          name,
          left,
          top,
          width,
          height,
          fillColor,
          strokeColor,
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created ellipse: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create ellipse: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_text",
  "Create a text shape in Frame0.",
  {
    type: z
      .enum(["label", "paragraph", "heading", "link", "normal"])
      .optional()
      .describe(
        "Type of the text shape to create. If type is 'paragraph', text width need to be updated using 'update_shape' tool."
      ),
    name: z.string().describe("Name of the text shape."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    left: z
      .number()
      .describe(
        "Left position of the text shape in the absolute coordinate system. Position need to be adjusted using 'move_shape' tool based on the width and height of the created text."
      ),
    top: z
      .number()
      .describe(
        "Top position of the text shape in the absolute coordinate system.  Position need to be adjusted using 'move_shape' tool based on the width and height of the created text."
      ),
    width: z
      .number()
      .optional()
      .describe(
        "Width of the text shape. if the type is 'paragraph' recommend to set width."
      ),
    text: z
      .string()
      .describe(
        "Plain text content to display of the text shape. Use newline character (0x0A) instead of '\\n' for new line. Dont's use HTML and CSS code in the text content."
      ),
    fontColor: z
      .string()
      .optional()
      .default("#000000")
      .describe("Font color in hex code of the text shape."),
    fontSize: z.number().optional().describe("Font size of the text shape."),
  },
  async ({
    type,
    name,
    parentId,
    left,
    top,
    width,
    text,
    fontColor,
    fontSize,
  }) => {
    try {
      const shapeId = await command(apiPort, "shape:create-shape", {
        type: "Text",
        shapeProps: trimObject({
          name,
          left,
          width,
          top,
          text,
          fontColor,
          fontSize,
          wordWrap: type === "paragraph",
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created text: " +
          JSON.stringify({ ...filterShape(data), textType: type })
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create text: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_line",
  "Create a line shape in Frame0.",
  {
    name: z.string().describe("Name of the line shape."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    x1: z.number().describe("X coordinate of the first point."),
    y1: z.number().describe("Y coordinate of the first point."),
    x2: z.number().describe("X coordinate of the second point."),
    y2: z.number().describe("Y coordinate of the second point."),
    strokeColor: z
      .string()
      .optional()
      .default("#000000")
      .describe(
        "Stroke color in hex code of the line shape. (e.g., black) - temp string type"
      ),
  },
  async ({ name, parentId, x1, y1, x2, y2, strokeColor }) => {
    try {
      const shapeId = await command(apiPort, "shape:create-shape", {
        type: "Line",
        shapeProps: trimObject({
          name,
          path: [
            [x1, y1],
            [x2, y2],
          ],
          tailEndType: "flat",
          headEndType: "flat",
          strokeColor,
          lineType: "straight",
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created line: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create line: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_polygon",
  "Create a polygon or polyline shape in Frame0.",
  {
    name: z.string().describe("Name of the polygon shape."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    points: z
      .array(
        z.object({
          x: z.number().describe("X coordinate of the point."),
          y: z.number().describe("Y coordinate of the point."),
        })
      )
      .min(3)
      .describe("Array of points defining the polygon shape."),
    closed: z
      .boolean()
      .optional()
      .default(true)
      .describe("Whether the polygon shape is closed or not. Default is true."),
    fillColor: z
      .string()
      .optional()
      .default("#ffffff")
      .describe(
        "Fill color in hex code of the polygon shape. (e.g., white) - temp string type"
      ),
    strokeColor: z
      .string()
      .optional()
      .default("#000000")
      .describe(
        "Stroke color in hex code of the line shape. (e.g., black) - temp string type"
      ),
  },
  async ({ name, parentId, points, closed, strokeColor }) => {
    try {
      const path = points.map((point) => [point.x, point.y]);
      const pathClosed =
        path[0][0] === path[path.length - 1][0] &&
        path[0][1] === path[path.length - 1][1];
      if (closed && !pathClosed) path.push(path[0]);
      const shapeId = await command(apiPort, "shape:create-shape", {
        type: "Line",
        shapeProps: trimObject({
          name,
          path,
          tailEndType: "flat",
          headEndType: "flat",
          strokeColor,
          lineType: "straight",
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created line: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create line: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_connector",
  "Create a connector shape in Frame0.",
  {
    name: z.string().describe("Name of the line shape."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    startId: z.string().describe("ID of the start shape."),
    endId: z.string().describe("ID of the end shape."),
    startArrowhead: z
      .enum(ARROWHEADS)
      .optional()
      .default("none")
      .describe("Start arrowhead of the line shape."),
    endArrowhead: z
      .enum(ARROWHEADS)
      .optional()
      .default("none")
      .describe("End arrowhead of the line shape."),
    strokeColor: z
      .string()
      .optional()
      .default("#000000")
      .describe("Stroke color in hex code of the line. shape"),
  },
  async ({
    name,
    parentId,
    startId,
    endId,
    startArrowhead,
    endArrowhead,
    strokeColor,
  }) => {
    try {
      const shapeId = await command(apiPort, "shape:create-connector", {
        tailId: startId,
        headId: endId,
        shapeProps: trimObject({
          name,
          tailEndType: convertArrowhead(startArrowhead || "none"),
          headEndType: convertArrowhead(endArrowhead || "none"),
          strokeColor,
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created connector: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create connector: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_icon",
  "Create an icon shape in Frame0.",
  {
    name: z
      .string()
      .describe(
        "The name of the icon shape to create. The name should be one of the result of 'get_available_icons' tool."
      ),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    left: z
      .number()
      .describe(
        "Left position of the icon shape in the absolute coordinate system."
      ),
    top: z
      .number()
      .describe(
        "Top position of the icon shape in the absolute coordinate system."
      ),
    size: z
      .enum(["small", "medium", "large", "extra-large"])
      .describe(
        "Size of the icon shape. 'small' is 16 x 16, 'medium' is 24 x 24, 'large' is 32 x 32, 'extra-large' is 48 x 48."
      ),
    strokeColor: z
      .string()
      .optional()
      .default("#000000")
      .describe(`Stroke color in hex code of the icon shape.`),
  },
  async ({ name, parentId, left, top, size, strokeColor }) => {
    try {
      const sizeValue = {
        small: 16,
        medium: 24,
        large: 32,
        "extra-large": 48,
      }[size];
      const shapeId = await command(apiPort, "shape:create-icon", {
        iconName: name,
        shapeProps: trimObject({
          left,
          top,
          width: sizeValue ?? 24,
          height: sizeValue ?? 24,
          strokeColor,
        }),
        parentId,
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created icon: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create icon: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "create_image",
  "Create an image shape in Frame0.",
  {
    name: z.string().describe("The name of the image shape to create."),
    parentId: z
      .string()
      .optional()
      .describe("ID of the parent shape. Typically frame ID."),
    mimeType: z
      .enum(["image/png", "image/jpeg", "image/webp", "image/svg+xml"])
      .describe("MIME type of the image."),
    imageData: z.string().describe("Base64 encoded image data."),
    left: z
      .number()
      .describe(
        "Left position of the image shape in the absolute coordinate system."
      ),
    top: z
      .number()
      .describe(
        "Top position of the image shape in the absolute coordinate system."
      ),
  },
  async ({ name, parentId, mimeType, imageData, left, top }) => {
    try {
      const shapeId = await command(apiPort, "shape:create-image", {
        mimeType,
        imageData,
        shapeProps: trimObject({
          name,
          left,
          top,
        }),
        parentId,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      return response.text(
        "Created image: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to create image: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "update_shape",
  "Update properties of a shape in Frame0.",
  {
    shapeId: z.string().describe("ID of the shape to update"),
    name: z.string().optional().describe("Name of the shape."),
    width: z.number().optional().describe("Width of the shape."),
    height: z.number().optional().describe("Height of the shape."),
    fillColor: z
      .string()
      .optional()
      .describe("Fill color in hex code of the shape."),
    strokeColor: z
      .string()
      .optional()
      .describe("Stroke color in hex code of the shape."),
    fontColor: z
      .string()
      .optional()
      .describe("Font color in hex code of the text shape."),
    fontSize: z.number().optional().describe("Font size of the text shape."),
    corners: z
      .array(z.number())
      .length(4)
      .optional()
      .describe(
        "Corner radius of the rectangle shape. Must be in the form of [left-top, right-top, right-bottom, left-bottom]."
      ),
    text: z
      .string()
      .optional()
      .describe(
        "Plain text content to display of the text shape. Don't include escape sequences and HTML and CSS code in the text content."
      ),
  },
  async ({
    shapeId,
    name,
    width,
    height,
    strokeColor,
    fillColor,
    fontColor,
    fontSize,
    corners,
    text,
  }) => {
    try {
      const updatedId = await command(apiPort, "shape:update-shape", {
        shapeId,
        shapeProps: trimObject({
          name,
          width,
          height,
          fillColor,
          strokeColor,
          fontColor,
          fontSize,
          corners,
          text,
        }),
        convertColors: true,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId: updatedId,
      });
      return response.text(
        "Updated shape: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to update shape: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "duplicate_shape",
  "Duplicate a shape in Frame0.",
  {
    shapeId: z.string().describe("ID of the shape to duplicate"),
    parentId: z
      .string()
      .optional()
      .describe(
        "ID of the parent shape where the duplicated shape will be added. If not provided, the duplicated shape will be added to the current page."
      ),
    dx: z
      .number()
      .optional()
      .describe("Delta X value by which the duplicated shape moves."),
    dy: z
      .number()
      .optional()
      .describe("Delta Y value by which the duplicated shape moves."),
  },
  async ({ shapeId, parentId, dx, dy }) => {
    try {
      const duplicatedShapeIdArray = await command(apiPort, "edit:duplicate", {
        shapeIdArray: [shapeId],
        parentId,
        dx,
        dy,
      });
      const duplicatedShapeId = duplicatedShapeIdArray[0];
      const data = await command(apiPort, "shape:get-shape", {
        shapeId: duplicatedShapeId,
      });
      return response.text(
        "Duplicated shape: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to duplicate shape: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "delete_shape",
  "Delete a shape in Frame0.",
  { shapeId: z.string().describe("ID of the shape to delete") },
  async ({ shapeId }) => {
    try {
      await command(apiPort, "edit:delete", {
        shapeIdArray: [shapeId],
      });
      return response.text("Deleted shape of id: " + shapeId);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to delete shape: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "search_icons",
  "Search icon shapes available in Frame0.",
  {
    keyword: z
      .string()
      .optional()
      .describe(
        "Search keyword to filter icon by name or tags (case-insensitive)"
      ),
  },
  async ({ keyword }) => {
    try {
      const data = await command(apiPort, "shape:get-available-icons", {});
      const icons = Array.isArray(data) ? data : [];
      const filtered = keyword
        ? icons.filter((icon: { name: string; tags: string[] }) => {
            if (
              typeof icon !== "object" ||
              !icon.name ||
              !Array.isArray(icon.tags)
            ) {
              return false;
            }
            const searchLower = keyword.toLowerCase();
            return (
              icon.name.toLowerCase().includes(searchLower) ||
              icon.tags.some((tag: string) =>
                tag.toLowerCase().includes(searchLower)
              )
            );
          })
        : icons;
      return response.text("Available icons: " + JSON.stringify(filtered));
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to search available icons: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "move_shape",
  "Move a shape in Frame0.",
  {
    shapeId: z.string().describe("ID of the shape to move"),
    dx: z.number().describe("Delta X"),
    dy: z.number().describe("Delta Y"),
  },
  async ({ shapeId, dx, dy }) => {
    try {
      await command(apiPort, "shape:move", {
        shapeId,
        dx,
        dy,
      });
      return response.text(`Moved shape (id: ${shapeId}) as (${dx}, ${dy})`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to move shape: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "align_shapes",
  "Align shapes in Frame0.",
  {
    alignType: z
      .enum([
        "bring-to-front",
        "send-to-back",
        "align-left",
        "align-right",
        "align-horizontal-center",
        "align-top",
        "align-bottom",
        "align-vertical-center",
        "distribute-horizontally",
        "distribute-vertically",
      ])
      .describe("Type of the alignment to apply."),
    shapeIdArray: z.array(z.string()).describe("Array of shape IDs to align"),
  },
  async ({ alignType, shapeIdArray }) => {
    const COMMAND = {
      "bring-to-front": "align:bring-to-front",
      "send-to-back": "align:send-to-back",
      "align-left": "align:align-left",
      "align-right": "align:align-right",
      "align-horizontal-center": "align:align-center",
      "align-top": "align:align-top",
      "align-bottom": "align:align-bottom",
      "align-vertical-center": "align:align-middle",
      "distribute-horizontally": "align:horizontal-distribute",
      "distribute-vertically": "align:vertical-distribute",
    };
    try {
      await command(apiPort, COMMAND[alignType], { shapeIdArray });
      return response.text("Shapes are aligned.");
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to align shapes: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "group",
  "Group shapes in Frame0.",
  {
    shapeIdArray: z.array(z.string()).describe("Array of shape IDs to group"),
    parentId: z
      .string()
      .optional()
      .describe(
        "ID of the parent shape where the group will be added. If not provided, the group will be added to the current page."
      ),
  },
  async ({ shapeIdArray, parentId }) => {
    try {
      const groupId = await command(apiPort, "shape:group", {
        shapeIdArray,
        parentId,
      });
      const data = await command(apiPort, "shape:get-shape", {
        shapeId: groupId,
      });
      return response.text(
        "Created group: " + JSON.stringify(filterShape(data))
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to group shapes: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "ungroup",
  "Ungroup a group in Frame0.",
  {
    groupId: z.string().describe("ID of the group to ungroup"),
  },
  async ({ groupId }) => {
    try {
      await command(apiPort, "shape:ungroup", {
        shapeIdArray: [groupId],
      });
      return response.text("Deleted group of id: " + groupId);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to ungroup shapes: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "set_link",
  "Set a link from a shape to a URL or a page in Frame0.",
  {
    shapeId: z.string().describe("ID of the shape to set link"),
    linkType: z
      .enum(["none", "web", "page", "action:backward"])
      .describe("Type of the link to set."),
    url: z
      .string()
      .optional()
      .describe("URL to set. Required if linkType is 'web'."),
    pageId: z
      .string()
      .optional()
      .describe("ID of the page to set. Required if linkType is 'page'."),
  },
  async ({ shapeId, linkType, url, pageId }) => {
    try {
      await command(apiPort, "shape:set-link", {
        shapeId,
        linkProps: trimObject({
          linkType,
          url,
          pageId,
        }),
      });
      return response.text(`A link is assigned to shape (id: ${shapeId})`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to set link: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "export_shape_as_image",
  "Export shape as image in Frame0.",
  {
    shapeId: z.string().describe("ID of the shape to export"),
    format: z
      .enum(["image/png", "image/jpeg", "image/webp"])
      .optional()
      .default("image/png")
      .describe("Image format to export."),
  },
  async ({ shapeId, format }) => {
    try {
      const data = await command(apiPort, "shape:get-shape", {
        shapeId,
      });
      const image = await command(apiPort, "file:export-image", {
        pageId: data.pageId,
        shapeIdArray: [shapeId],
        format,
        fillBackground: true,
      });
      return response.image(format, image);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to export shape as image: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "add_page",
  "Add a new page in Frame0. The added page becomes the current page.",
  {
    name: z.string().describe("Name of the page to add."),
  },
  async ({ name }) => {
    try {
      const pageData = await command(apiPort, "page:add", {
        pageProps: trimObject({ name }),
      });
      return response.text(`Added page: ${JSON.stringify(pageData)}`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to add page: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "update_page",
  "Update a page in Frame0.",
  {
    pageId: z.string().describe("ID of the page to update."),
    name: z.string().describe("Name of the page."),
  },
  async ({ pageId, name }) => {
    try {
      const updatedPageId = await command(apiPort, "page:update", {
        pageId,
        pageProps: trimObject({ name }),
      });
      const pageData = await command(apiPort, "page:get", {
        pageId: updatedPageId,
      });
      return response.text(`Updated page: ${JSON.stringify(pageData)}`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to update page: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "duplicate_page",
  "Duplicate a page in Frame0.",
  {
    pageId: z.string().describe("ID of the page to duplicate"),
    name: z.string().optional().describe("Name of the duplicated page."),
  },
  async ({ pageId, name }) => {
    try {
      const duplicatedPageId = await command(apiPort, "page:duplicate", {
        pageId,
        pageProps: trimObject({ name }),
      });
      const pageData = await command(apiPort, "page:get", {
        pageId: duplicatedPageId,
        exportShapes: true,
      });
      return response.text(`Duplicated page data: ${JSON.stringify(pageData)}`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to duplicate page: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "delete_page",
  "Delete a page in Frame0.",
  {
    pageId: z.string().describe("ID of the page to delete"),
  },
  async ({ pageId }) => {
    try {
      await command(apiPort, "page:delete", {
        pageId,
      });
      return response.text(`Deleted page ID is${pageId}`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to delete page: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "get_current_page_id",
  "Get ID of the current page in Frame0.",
  {},
  async () => {
    try {
      const pageId = await command(apiPort, "page:get-current-page");
      return response.text(`Current page ID is ${pageId},`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to get current page: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "set_current_page_by_id",
  "Set current page by ID in Frame0.",
  {
    pageId: z.string().describe("ID of the page to set as current page."),
  },
  async ({ pageId }) => {
    try {
      await command(apiPort, "page:set-current-page", {
        pageId,
      });
      return response.text(`Current page ID is ${pageId}`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to set current page: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "get_page",
  "Get page data in Frame0.",
  {
    pageId: z
      .string()
      .optional()
      .describe(
        "ID of the page to get data. If not provided, the current page data is returned."
      ),
    exportShapes: z
      .boolean()
      .optional()
      .default(true)
      .describe("Export shapes data included in the page."),
  },
  async ({ pageId, exportShapes }) => {
    try {
      const pageData = await command(apiPort, "page:get", {
        pageId,
        exportShapes,
      });
      return response.text(
        `The page data: ${JSON.stringify(filterPage(pageData))}`
      );
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to get page data: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "get_all_pages",
  "Get all pages data in Frame0.",
  {
    exportShapes: z
      .boolean()
      .optional()
      .default(false)
      .describe("Export shapes data included in the page data."),
  },
  async ({ exportShapes }) => {
    try {
      const docData = await command(apiPort, "doc:get", {
        exportPages: true,
        exportShapes,
      });
      if (!Array.isArray(docData.children)) docData.children = [];
      const pageArray = docData.children.map((page: any) => filterPage(page));
      return response.text(`The all pages data: ${JSON.stringify(pageArray)}`);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to get page data: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

server.tool(
  "export_page_as_image",
  "Export page as image in Frame0.",
  {
    pageId: z
      .string()
      .optional()
      .describe(
        "ID of the page to export. If not provided, the current page is used."
      ),
    format: z
      .enum(["image/png", "image/jpeg", "image/webp"])
      .optional()
      .default("image/png")
      .describe("Image format to export."),
  },
  async ({ pageId, format }) => {
    try {
      const image = await command(apiPort, "file:export-image", {
        pageId,
        format,
        fillBackground: true,
      });
      return response.image(format, image);
    } catch (error) {
      console.error(error);
      return response.error(
        JsonRpcErrorCode.InternalError,
        `Failed to export page as image: ${error instanceof Error ? error.message : String(error)}`
      );
    }
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Frame0 MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Error starting server:", error);
  process.exit(1);
});

```