drawio-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # Draw.io MCP server
2
+
3
+ Let's do some Vibe Diagramming with the most wide-spread diagramming tool called Draw.io (Diagrams.net).
4
+
5
+ ## Introduction
6
+
7
+ The Draw.io MCP server is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) implementation that brings powerful diagramming capabilities to AI agentic systems. This integration enables:
8
+
9
+ - **Seamless Draw.io Integration**: Connect your MCP-powered applications with Draw.io's rich diagramming functionality
10
+ - **Programmatic Diagram Control**: Create, modify, and manage diagram content through MCP commands
11
+ - **Intelligent Diagram Analysis**: Retrieve detailed information about diagrams and their components for processing by AI agents
12
+ - **Agentic System Development**: Build sophisticated AI workflows that incorporate visual modeling and diagram automation
13
+
14
+ As an MCP-compliant tool, it follows the standard protocol for tool integration, making it compatible with any MCP client. This implementation is particularly valuable for creating AI systems that need to:
15
+ - Generate architectural diagrams
16
+ - Visualize complex relationships
17
+ - Annotate technical documentation
18
+ - Create flowcharts and process maps programmatically
19
+
20
+ The tool supports bidirectional communication, allowing both control of Draw.io instances and extraction of diagram information for further processing by AI agents in your MCP ecosystem.
21
+
22
+ ## Requirements
23
+
24
+ To use the Draw.io MCP server, you'll need:
25
+
26
+ ### Core Components
27
+ - **Node.js** (v18 or higher) - Runtime environment for the MCP server
28
+ - **Draw.io MCP Browser Extension** - Enables communication between Draw.io and the MCP server
29
+
30
+ ### MCP Ecosystem
31
+ - **MCP Client** (e.g., [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector)) - For testing and debugging the integration
32
+ - **LLM with Tools Support** - Any language model capable of handling MCP tool calls (e.g., GPT-4, Claude 3, etc.)
33
+
34
+ ### Optional for Development
35
+ - **pnpm** - Preferred package manager
36
+ - **Chrome DevTools** - For debugging when using `--inspect` flag
37
+
38
+ Note: The Draw.io desktop app or web version must be accessible to the system where the MCP server runs.
39
+
40
+ ## Installation
41
+
42
+ ### Connecting with Claude Desktop
43
+
44
+ 1. Install [Claude Desktop](https://claude.ai/download)
45
+ 2. Open or create the configuration file:
46
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
47
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
48
+
49
+ 3. Update it to include this server:
50
+
51
+ <details>
52
+ <summary>Using <code>npm</code></summary>
53
+
54
+ ```json
55
+ {
56
+ "mcpServers":{
57
+ "drawio":{
58
+ "command":"npx",
59
+ "args":[
60
+ "-y",
61
+ "drawio-mcp-server"
62
+ ]
63
+ }
64
+ }
65
+ }
66
+ ```
67
+ </details>
68
+
69
+ <details>
70
+ <summary>Using <code>pnpm</code></summary>
71
+
72
+ ```json
73
+ {
74
+ "mcpServers":{
75
+ "drawio":{
76
+ "command":"pnpm",
77
+ "args":[
78
+ "dlx",
79
+ "drawio-mcp-server"
80
+ ]
81
+ }
82
+ }
83
+ }
84
+ ```
85
+ </details>
86
+
87
+ 4. Restart Claude Desktop
88
+
89
+ ### Connecting with oterm
90
+
91
+ This is an alternative MCP client in case you like terminal and you plan to connect to your own Ollama instance.
92
+
93
+ The configuration is usually in: ~/.local/share/oterm/config.json
94
+
95
+ <details>
96
+ <summary>Using <code>npm</code></summary>
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "drawio": {
102
+ "command": "npx",
103
+ "args": [
104
+ "-y",
105
+ "drawio-mcp-server"
106
+ ]
107
+ }
108
+ }
109
+ }
110
+ ```
111
+ </details>
112
+
113
+ <details>
114
+ <summary>Using <code>pnpm</code></summary>
115
+
116
+ ```json
117
+ {
118
+ "mcpServers": {
119
+ "drawio": {
120
+ "command": "pnpm",
121
+ "args": [
122
+ "dlx",
123
+ "drawio-mcp-server"
124
+ ]
125
+ }
126
+ }
127
+ }
128
+ ```
129
+ </details>
130
+
131
+ ### Browser Extension Setup
132
+
133
+ In order to control the Draw.io diagram, you need to install dedicated Browser Extension.
134
+
135
+ 1. Open [Draw.io in your browser](https://app.diagrams.net/)
136
+ 2. Install the Draw.io MCP Browser Extension from a web store (**still pending review**) or [use other means](https://github.com/lgazo/drawio-mcp-extension)
137
+ <p>
138
+ <a href="https://chrome.google.com/webstore/detail/drawio-mcp-extension/okdbbjbbccdhhfaefmcmekalmmdjjide">
139
+ <picture>
140
+ <source srcset="https://i.imgur.com/XBIE9pk.png" media="(prefers-color-scheme: dark)" />
141
+ <img height="58" src="https://i.imgur.com/oGxig2F.png" alt="Chrome Web Store" /></picture
142
+ ></a>
143
+ <a href="https://addons.mozilla.org/en-US/firefox/addon/drawio-mcp-extension/">
144
+ <picture>
145
+ <source srcset="https://i.imgur.com/ZluoP7T.png" media="(prefers-color-scheme: dark)" />
146
+ <img height="58" src="https://i.imgur.com/4PobQqE.png" alt="Firefox add-ons" /></picture
147
+ ></a>
148
+ </p>
149
+ 3. Ensure it is connected, the Extension icon should indicate green signal overlay <img alt="Extension connected" src="https://raw.githubusercontent.com/lgazo/drawio-mcp-extension/refs/heads/main/public/icon/logo_connected_32.png" />
150
+
151
+
152
+ ## Features
153
+
154
+ The Draw.io MCP server provides the following tools for programmatic diagram interaction:
155
+
156
+ ### Diagram Inspection Tools
157
+ - **`get-selected-cell`**
158
+ Retrieves the currently selected cell in Draw.io with all its attributes
159
+ *Returns*: JSON object containing cell properties (ID, geometry, style, value, etc.)
160
+
161
+ - **`get-shape-categories`**
162
+ Retrieves available shape categories from the diagram's library
163
+ *Returns*: Array of category objects with their IDs and names
164
+
165
+ - **`get-shapes-in-category`**
166
+ Retrieves all shapes in a specified category from the diagram's library
167
+ *Parameters*:
168
+ - `category_id`: Identifier of the category to retrieve shapes from
169
+ *Returns*: Array of shape objects with their properties and styles
170
+
171
+ - **`get-shape-by-name`**
172
+ Retrieves a specific shape by its name from all available shapes
173
+ *Parameters*:
174
+ - `shape_name`: Name of the shape to retrieve
175
+ *Returns*: Shape object including its category and style information
176
+
177
+ ### Diagram Modification Tools
178
+ - **`add-rectangle`**
179
+ Creates a new rectangle shape on the active Draw.io page with customizable properties:
180
+ - Position (`x`, `y` coordinates)
181
+ - Dimensions (`width`, `height`)
182
+ - Text content
183
+ - Visual style (fill color, stroke, etc. using Draw.io style syntax)
184
+
185
+ - **`add-edge`**
186
+ Creates a connection between two cells (vertexes)
187
+ *Parameters*:
188
+ - `source_id`: ID of the source cell
189
+ - `target_id`: ID of the target cell
190
+ - `text`: Optional text label for the edge
191
+ - `style`: Optional style properties for the edge
192
+
193
+ - **`delete-cell-by-id`**
194
+ Removes a specified cell from the diagram
195
+ *Parameters*:
196
+ - `cell_id`: ID of the cell to delete
197
+
198
+ - **`add-cell-of-shape`**
199
+ Adds a new cell of a specific shape type from the diagram's library
200
+ *Parameters*:
201
+ - `shape_name`: Name of the shape to create
202
+ - `x`, `y`: Position coordinates (optional)
203
+ - `width`, `height`: Dimensions (optional)
204
+ - `text`: Optional text content
205
+ - `style`: Optional additional style properties
206
+
207
+ ## Related Resources
208
+
209
+ [Architecture](./ARCHITECTURE.md)
210
+
211
+ [Development](./DEVELOPMENT.md)
@@ -0,0 +1,23 @@
1
+ import { bus_reply_stream, bus_request_stream, } from "./types.js";
2
+ export function create_bus(log) {
3
+ return function (emitter) {
4
+ const listeners = [];
5
+ const bus = {
6
+ send_to_extension: (request) => {
7
+ log.debug(`[bus] sending to Extension`, request);
8
+ emitter.emit(bus_request_stream, request);
9
+ },
10
+ on_reply_from_extension: (event_name, reply) => {
11
+ const listener = (emitter_data) => {
12
+ log.debug(`[bus] received from Extension`, emitter_data);
13
+ if (emitter_data && emitter_data.__event === event_name) {
14
+ reply(emitter_data);
15
+ }
16
+ };
17
+ emitter.on(bus_reply_stream, listener);
18
+ listeners.push(reply);
19
+ },
20
+ };
21
+ return bus;
22
+ };
23
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, jest } from "@jest/globals";
2
+ import EventEmitter from "node:events";
3
+ import { create_bus } from "./emitter_bus.js";
4
+ import { bus_request_stream, bus_reply_stream } from "./types.js";
5
+ import { create_logger } from "./standard_console_logger.js";
6
+ describe("create_bus", () => {
7
+ let emitter;
8
+ let bus;
9
+ const log = create_logger();
10
+ beforeEach(() => {
11
+ emitter = new EventEmitter();
12
+ bus = create_bus(log)(emitter);
13
+ });
14
+ it("should send requests to extension via emitter", () => {
15
+ const mockRequest = { type: "test_request", data: "test" };
16
+ const emitSpy = jest.spyOn(emitter, "emit");
17
+ bus.send_to_extension(mockRequest);
18
+ expect(emitSpy).toHaveBeenCalledWith(bus_request_stream, mockRequest);
19
+ });
20
+ it("should register reply handlers and call them when matching events arrive", () => {
21
+ const mockReply1 = jest.fn();
22
+ const mockReply2 = jest.fn();
23
+ const eventName1 = "event1";
24
+ const eventName2 = "event2";
25
+ const matchingEvent1 = { __event: eventName1, data: "test1" };
26
+ const matchingEvent2 = { __event: eventName2, data: "test2" };
27
+ const nonMatchingEvent = { __event: "other_event", data: "test3" };
28
+ bus.on_reply_from_extension(eventName1, mockReply1);
29
+ bus.on_reply_from_extension(eventName2, mockReply2);
30
+ emitter.emit(bus_reply_stream, matchingEvent1);
31
+ emitter.emit(bus_reply_stream, matchingEvent2);
32
+ emitter.emit(bus_reply_stream, nonMatchingEvent);
33
+ expect(mockReply1).toHaveBeenCalledWith(matchingEvent1);
34
+ expect(mockReply2).toHaveBeenCalledWith(matchingEvent2);
35
+ expect(mockReply1).not.toHaveBeenCalledWith(nonMatchingEvent);
36
+ expect(mockReply2).not.toHaveBeenCalledWith(nonMatchingEvent);
37
+ });
38
+ it("should track all registered listeners", () => {
39
+ // This test assumes the listeners array is accessible or there's a way to verify listeners
40
+ // Since the original code doesn't expose the listeners array, we'll test indirectly
41
+ const mockReply1 = jest.fn();
42
+ const mockReply2 = jest.fn();
43
+ bus.on_reply_from_extension("event1", mockReply1);
44
+ bus.on_reply_from_extension("event2", mockReply2);
45
+ // Verify listeners are working by emitting events
46
+ const event1 = { __event: "event1", data: "test" };
47
+ const event2 = { __event: "event2", data: "test" };
48
+ emitter.emit(bus_reply_stream, event1);
49
+ emitter.emit(bus_reply_stream, event2);
50
+ expect(mockReply1).toHaveBeenCalledWith(event1);
51
+ expect(mockReply2).toHaveBeenCalledWith(event2);
52
+ });
53
+ it("should only call the correct reply handler for each event", () => {
54
+ const mockReply1 = jest.fn();
55
+ const mockReply2 = jest.fn();
56
+ const eventName1 = "event1";
57
+ const eventName2 = "event2";
58
+ const matchingEvent = { __event: eventName1, data: "test" };
59
+ bus.on_reply_from_extension(eventName1, mockReply1);
60
+ bus.on_reply_from_extension(eventName2, mockReply2);
61
+ emitter.emit(bus_reply_stream, matchingEvent);
62
+ expect(mockReply1).toHaveBeenCalledWith(matchingEvent);
63
+ expect(mockReply2).not.toHaveBeenCalled();
64
+ });
65
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Removes top-level fields from an object that start with '__'
3
+ * @param obj The input object to process
4
+ * @returns A new object with internal fields (starting with '__') removed
5
+ */
6
+ export function strip_internal_fields(obj) {
7
+ const result = {};
8
+ for (const key in obj) {
9
+ if (!key.startsWith("__")) {
10
+ result[key] = obj[key];
11
+ }
12
+ }
13
+ return result;
14
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { strip_internal_fields } from "./events.js";
3
+ describe("strip_internal_fields", () => {
4
+ describe("when object contains internal fields (starting with __)", () => {
5
+ it("should remove all fields that start with __", () => {
6
+ const input = {
7
+ __private: "secret-data",
8
+ publicField: "visible",
9
+ __config: { key: "value" },
10
+ count: 42,
11
+ __meta: { timestamp: "2023-01-01" },
12
+ };
13
+ const result = strip_internal_fields(input);
14
+ expect(result).toEqual({
15
+ publicField: "visible",
16
+ count: 42,
17
+ });
18
+ });
19
+ it("should return an empty object when all fields are internal", () => {
20
+ const input = {
21
+ __internal: true,
22
+ __version: "1.0.0",
23
+ };
24
+ const result = strip_internal_fields(input);
25
+ expect(result).toEqual({});
26
+ });
27
+ it("should not mutate the original object", () => {
28
+ const input = {
29
+ __temp: "value",
30
+ name: "original",
31
+ };
32
+ const originalInput = { ...input };
33
+ strip_internal_fields(input);
34
+ expect(input).toEqual(originalInput);
35
+ });
36
+ });
37
+ describe("when object contains no internal fields", () => {
38
+ it("should return the object unchanged when no fields start with __", () => {
39
+ const input = {
40
+ id: "abc123",
41
+ name: "Test Object",
42
+ metadata: { created: "2023-01-01" },
43
+ };
44
+ const result = strip_internal_fields(input);
45
+ expect(result).toStrictEqual(input); // Should return same reference
46
+ expect(result).toEqual(input); // With same content
47
+ });
48
+ it("should return an empty object when input is empty", () => {
49
+ const input = {};
50
+ const result = strip_internal_fields(input);
51
+ expect(result).toEqual({});
52
+ });
53
+ });
54
+ describe("TypeScript type behavior", () => {
55
+ it("should properly exclude __ prefixed fields from the return type", () => {
56
+ const input = {
57
+ id: "123",
58
+ __internal: true,
59
+ name: "Test",
60
+ __version: 1,
61
+ };
62
+ const result = strip_internal_fields(input);
63
+ // This test is mostly for TypeScript type checking
64
+ expect(result).toEqual({
65
+ id: "123",
66
+ name: "Test",
67
+ });
68
+ // @ts-expect-error - __internal should not exist in type
69
+ expect(result.__internal).toBeUndefined();
70
+ // @ts-expect-error - __version should not exist in type
71
+ expect(result.__version).toBeUndefined();
72
+ });
73
+ });
74
+ });
package/build/index.js ADDED
@@ -0,0 +1,184 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import EventEmitter from "node:events";
5
+ import uWS from "uWebSockets.js";
6
+ import { bus_reply_stream, bus_request_stream, } from "./types.js";
7
+ import { create_bus } from "./emitter_bus.js";
8
+ import { default_tool } from "./tool.js";
9
+ import { nanoid_id_generator } from "./nanoid_id_generator.js";
10
+ import { create_logger } from "./mcp_console_logger.js";
11
+ const log = create_logger();
12
+ const emitter = new EventEmitter();
13
+ const conns = [];
14
+ const bus_to_ws_forwarder_listener = (event) => {
15
+ log.debug(`[bridge] received; passing to #${conns.length}`, event);
16
+ for (let i = 0; i < conns.length; i++) {
17
+ try {
18
+ conns[i].send(JSON.stringify(event));
19
+ }
20
+ catch (e) {
21
+ log.debug(`[bridge] error forwarding request at conn = ${i}`);
22
+ }
23
+ }
24
+ };
25
+ emitter.on(bus_request_stream, bus_to_ws_forwarder_listener);
26
+ const ws_handler = {
27
+ open: (ws) => {
28
+ log.debug("A WebSocket connected!");
29
+ conns.push(ws);
30
+ },
31
+ message: (ws, message, isBinary) => {
32
+ // ws.send(message, isBinary);
33
+ const decoder = new TextDecoder();
34
+ const str = decoder.decode(message);
35
+ const json = JSON.parse(str);
36
+ log.debug(`[ws] received from Extension`, json);
37
+ // const event_name = message.__event;
38
+ emitter.emit(bus_reply_stream, json);
39
+ },
40
+ close: (ws, code, message) => {
41
+ log.debug("WebSocket closed");
42
+ //todo remove conn
43
+ },
44
+ };
45
+ const app = uWS
46
+ .App()
47
+ .ws("/*", ws_handler)
48
+ .listen(3000, (token) => {
49
+ if (token) {
50
+ log.debug("Listening to port 3000");
51
+ }
52
+ });
53
+ // Create server instance
54
+ const server = new McpServer({
55
+ name: "drawio-mcp-server",
56
+ version: "1.0.0",
57
+ capabilities: {
58
+ resources: {},
59
+ tools: {},
60
+ },
61
+ });
62
+ const bus = create_bus(log)(emitter);
63
+ const id_generator = nanoid_id_generator();
64
+ const context = {
65
+ bus,
66
+ id_generator,
67
+ log,
68
+ };
69
+ const TOOL_get_selected_cell = "get-selected-cell";
70
+ server.tool(TOOL_get_selected_cell, "This tool allows you to retrieve selected cell (whether vertex or edge) on the current page of a Draw.io diagram. The response is a JSON containing attributes of the cell.", {}, default_tool(TOOL_get_selected_cell, context));
71
+ const TOOL_add_rectangle = "add-rectangle";
72
+ server.tool(TOOL_add_rectangle, "This tool allows you to add new Rectangle vertex cell (object) on the current page of a Draw.io diagram. It accepts multiple optional input parameter.", {
73
+ x: z
74
+ .number()
75
+ .optional()
76
+ .describe("X-axis position of the Rectangle vertex cell")
77
+ .default(100),
78
+ y: z
79
+ .number()
80
+ .optional()
81
+ .describe("Y-axis position of the Rectangle vertex cell")
82
+ .default(100),
83
+ width: z
84
+ .number()
85
+ .optional()
86
+ .describe("Width of the Rectangle vertex cell")
87
+ .default(200),
88
+ height: z
89
+ .number()
90
+ .optional()
91
+ .describe("Height of the Rectangle vertex cell")
92
+ .default(100),
93
+ text: z
94
+ .string()
95
+ .optional()
96
+ .describe("Text content placed inside of the Rectangle vertex cell")
97
+ .default("New Cell"),
98
+ style: z
99
+ .string()
100
+ .optional()
101
+ .describe("Semi-colon separated list of Draw.io visual styles, in the form of `key=value`. Example: `whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;`")
102
+ .default("whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"),
103
+ }, default_tool(TOOL_add_rectangle, context));
104
+ const TOOL_add_edge = "add-edge";
105
+ server.tool(TOOL_add_edge, "This tool creates an edge, sometimes called also a relation, between two vertexes (cells).", {
106
+ source_id: z
107
+ .string()
108
+ .describe("Source ID of a cell. It is represented by `id` attribute."),
109
+ target_id: z
110
+ .string()
111
+ .describe("Target ID of a cell. It is represented by `id` attribute."),
112
+ text: z
113
+ .string()
114
+ .optional()
115
+ .describe("Text content placed over the edge cell"),
116
+ style: z
117
+ .string()
118
+ .optional()
119
+ .describe("Semi-colon separated list of Draw.io visual styles, in the form of `key=value`. Example: `edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;`")
120
+ .default("edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;"),
121
+ }, default_tool(TOOL_add_edge, context));
122
+ const TOOL_delete_cell_by_id = "delete-cell-by-id";
123
+ server.tool(TOOL_delete_cell_by_id, "Deletes a cell, whether it is a vertex or edge.", {
124
+ cell_id: z
125
+ .string()
126
+ .describe("The ID of a cell to delete. The cell can be either vertex or edge. The ID is located in `id` attribute."),
127
+ }, default_tool(TOOL_delete_cell_by_id, context));
128
+ const TOOL_get_shape_categories = "get-shape-categories";
129
+ server.tool(TOOL_get_shape_categories, "Retrieves available shape categories from the diagram's library. Library is split into multiple categories.", {}, default_tool(TOOL_get_shape_categories, context));
130
+ const TOOL_get_shapes_in_category = "get-shapes-in-category";
131
+ server.tool(TOOL_get_shapes_in_category, "Retrieve all shapes in the provided category from the diagram's library. A shape primarily contains `style` based on which you can create new vertex cells.", {
132
+ category_id: z
133
+ .string()
134
+ .describe("Identifier (ID / key) of the category from which all the shapes should be retrieved."),
135
+ }, default_tool(TOOL_get_shapes_in_category, context));
136
+ const TOOL_get_shape_by_name = "get-shape-by-name";
137
+ server.tool(TOOL_get_shape_by_name, "Retrieve a specific shape by its name from all available shapes in the diagram's library. It returns the shape and also the category it belongs.", {
138
+ shape_name: z
139
+ .string()
140
+ .describe("Name of the shape to retrieve from the shape library of the current diagram."),
141
+ }, default_tool(TOOL_get_shape_by_name, context));
142
+ const TOOL_add_cell_of_shape = "add-cell-of-shape";
143
+ server.tool(TOOL_add_cell_of_shape, "This tool allows you to add new vertex cell (object) on the current page of a Draw.io diagram by its shape name. It accepts multiple optional input parameter.", {
144
+ shape_name: z
145
+ .string()
146
+ .describe("Name of the shape to retrieved from the shape library of the current diagram."),
147
+ x: z
148
+ .number()
149
+ .optional()
150
+ .describe("X-axis position of the vertex cell of the shape")
151
+ .default(100),
152
+ y: z
153
+ .number()
154
+ .optional()
155
+ .describe("Y-axis position of the vertex cell of the shape")
156
+ .default(100),
157
+ width: z
158
+ .number()
159
+ .optional()
160
+ .describe("Width of the vertex cell of the shape")
161
+ .default(200),
162
+ height: z
163
+ .number()
164
+ .optional()
165
+ .describe("Height of the vertex cell of the shape")
166
+ .default(100),
167
+ text: z
168
+ .string()
169
+ .optional()
170
+ .describe("Text content placed inside of the vertex cell of the shape"),
171
+ style: z
172
+ .string()
173
+ .optional()
174
+ .describe("Semi-colon separated list of Draw.io visual styles, in the form of `key=value`. Example: `whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;`"),
175
+ }, default_tool(TOOL_add_cell_of_shape, context));
176
+ async function main() {
177
+ const transport = new StdioServerTransport();
178
+ await server.connect(transport);
179
+ log.debug("Draw.io MCP Server running on stdio");
180
+ }
181
+ main().catch((error) => {
182
+ log.debug("Fatal error in main():", error);
183
+ process.exit(1);
184
+ });
@@ -0,0 +1,10 @@
1
+ export function create_logger() {
2
+ return {
3
+ log: (level, message, data) => {
4
+ return console.error(message, data);
5
+ },
6
+ debug: (message, data) => {
7
+ return console.error(message, data);
8
+ },
9
+ };
10
+ }
@@ -0,0 +1,51 @@
1
+ import { jest } from "@jest/globals";
2
+ import { create_logger } from "./mcp_console_logger.js";
3
+ describe("create_logger", () => {
4
+ let originalConsoleError;
5
+ let mockConsoleError;
6
+ beforeEach(() => {
7
+ // Save original console.error
8
+ originalConsoleError = console.error;
9
+ // Create mock for console.error
10
+ mockConsoleError = jest.fn();
11
+ console.error = mockConsoleError;
12
+ });
13
+ afterEach(() => {
14
+ // Restore original console.error
15
+ console.error = originalConsoleError;
16
+ // Clear all mocks
17
+ jest.clearAllMocks();
18
+ });
19
+ it("should return a Logger object with log and debug methods", () => {
20
+ const logger = create_logger();
21
+ expect(logger).toBeDefined();
22
+ expect(typeof logger.log).toBe("function");
23
+ expect(typeof logger.debug).toBe("function");
24
+ });
25
+ it("log method should call console.error with message and data", () => {
26
+ const logger = create_logger();
27
+ const testMessage = "test message";
28
+ const testData = { key: "value" };
29
+ const testLevel = "info";
30
+ logger.log(testLevel, testMessage, testData);
31
+ expect(mockConsoleError).toHaveBeenCalledTimes(1);
32
+ expect(mockConsoleError).toHaveBeenCalledWith(testMessage, testData);
33
+ });
34
+ it("debug method should call console.error with message and data", () => {
35
+ const logger = create_logger();
36
+ const testMessage = "debug message";
37
+ const testData = { debug: true };
38
+ logger.debug(testMessage, testData);
39
+ expect(mockConsoleError).toHaveBeenCalledTimes(1);
40
+ expect(mockConsoleError).toHaveBeenCalledWith(testMessage, testData);
41
+ });
42
+ it("should handle undefined data parameter", () => {
43
+ const logger = create_logger();
44
+ const testMessage = "message without data";
45
+ logger.log("warn", testMessage);
46
+ expect(mockConsoleError).toHaveBeenCalledWith(testMessage, undefined);
47
+ mockConsoleError.mockClear();
48
+ logger.debug(testMessage);
49
+ expect(mockConsoleError).toHaveBeenCalledWith(testMessage, undefined);
50
+ });
51
+ });
@@ -0,0 +1,6 @@
1
+ import { nanoid } from "nanoid";
2
+ export function nanoid_id_generator() {
3
+ return {
4
+ generate: () => nanoid(),
5
+ };
6
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { nanoid_id_generator } from "./nanoid_id_generator.js";
3
+ describe("nanoid_id_generator", () => {
4
+ it("should return an object with a generate function", () => {
5
+ const generator = nanoid_id_generator();
6
+ expect(generator).toHaveProperty("generate");
7
+ expect(typeof generator.generate).toBe("function");
8
+ });
9
+ it("should generate a string ID", () => {
10
+ const generator = nanoid_id_generator();
11
+ const id = generator.generate();
12
+ expect(typeof id).toBe("string");
13
+ expect(id.length).toBeGreaterThan(0);
14
+ });
15
+ it("should generate different IDs on subsequent calls", () => {
16
+ const generator = nanoid_id_generator();
17
+ const id1 = generator.generate();
18
+ const id2 = generator.generate();
19
+ expect(id1).not.toBe(id2);
20
+ });
21
+ it("should generate IDs of default length (21 characters)", () => {
22
+ const generator = nanoid_id_generator();
23
+ const id = generator.generate();
24
+ expect(id).toHaveLength(21);
25
+ });
26
+ });
@@ -0,0 +1,10 @@
1
+ export function create_logger() {
2
+ return {
3
+ log: (level, message, data) => {
4
+ return console.log(message, data);
5
+ },
6
+ debug: (message, data) => {
7
+ return console.debug(message, data);
8
+ },
9
+ };
10
+ }
package/build/tool.js ADDED
@@ -0,0 +1,40 @@
1
+ import { strip_internal_fields } from "./events.js";
2
+ export function build_channel({ bus, id_generator, log }, event_name, handler) {
3
+ const fn = async (_args, _extra) => {
4
+ const request_id = id_generator.generate();
5
+ // const event_name = `get-selected-cell`;
6
+ const reply_name = `${event_name}.${request_id}`;
7
+ bus.send_to_extension({
8
+ __event: event_name,
9
+ __request_id: request_id,
10
+ ..._args,
11
+ });
12
+ log.debug(`[${event_name}] emitted, waiting for reply @${reply_name}`);
13
+ const p = new Promise((resolve, _reject) => {
14
+ log.debug(`[${event_name}] waiting for response @${reply_name}`);
15
+ bus.on_reply_from_extension(reply_name, (reply) => {
16
+ // bus.on(reply_name, (args) => {
17
+ log.debug(`[${reply_name}] received response`, reply);
18
+ const data = strip_internal_fields(reply);
19
+ const response = handler(data);
20
+ resolve(response);
21
+ });
22
+ });
23
+ return p;
24
+ };
25
+ return fn;
26
+ }
27
+ export function default_tool(name, context) {
28
+ const fn = build_channel(context, name, (reply) => {
29
+ const response = {
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: JSON.stringify(reply),
34
+ },
35
+ ],
36
+ };
37
+ return response;
38
+ });
39
+ return fn;
40
+ }
@@ -0,0 +1,104 @@
1
+ import { jest } from "@jest/globals";
2
+ import { build_channel, default_tool } from "./tool.js";
3
+ import { create_logger } from "./standard_console_logger.js";
4
+ describe("build_channel", () => {
5
+ let mockBus;
6
+ let mockIdGenerator;
7
+ let context;
8
+ const mockHandler = jest.fn();
9
+ const log = create_logger();
10
+ beforeEach(() => {
11
+ mockBus = {
12
+ send_to_extension: jest.fn(),
13
+ on_reply_from_extension: jest.fn(),
14
+ };
15
+ mockIdGenerator = {
16
+ generate: jest.fn().mockReturnValue("123"),
17
+ };
18
+ context = {
19
+ bus: mockBus,
20
+ id_generator: mockIdGenerator,
21
+ log,
22
+ };
23
+ mockHandler.mockReset();
24
+ });
25
+ it("should create a function that sends a message via bus", async () => {
26
+ const eventName = "test-event";
27
+ const toolFn = build_channel(context, eventName, mockHandler);
28
+ const args = { key: "value" };
29
+ const extra = {};
30
+ const promise = toolFn(args, extra);
31
+ expect(mockBus.send_to_extension).toHaveBeenCalledWith({
32
+ __event: eventName,
33
+ __request_id: "123",
34
+ key: "value",
35
+ });
36
+ });
37
+ it("should wait for reply and call handler with response", async () => {
38
+ const eventName = "test-event";
39
+ const toolFn = build_channel(context, eventName, mockHandler);
40
+ const mockResponse = {
41
+ content: [{ type: "text", text: "response" }],
42
+ };
43
+ mockHandler.mockReturnValue(mockResponse);
44
+ const promise = toolFn({}, {});
45
+ // Simulate reply callback
46
+ const replyCallback = mockBus.on_reply_from_extension.mock.calls[0][1];
47
+ replyCallback({ data: "test" });
48
+ const result = await promise;
49
+ expect(mockBus.on_reply_from_extension).toHaveBeenCalledWith("test-event.123", expect.any(Function));
50
+ expect(mockHandler).toHaveBeenCalledWith({ data: "test" });
51
+ expect(result).toEqual(mockResponse);
52
+ });
53
+ it("should use correct reply channel name format", async () => {
54
+ mockIdGenerator.generate.mockReturnValue("456");
55
+ const eventName = "another-event";
56
+ const toolFn = build_channel(context, eventName, mockHandler);
57
+ toolFn({}, {});
58
+ expect(mockBus.on_reply_from_extension).toHaveBeenCalledWith("another-event.456", expect.any(Function));
59
+ });
60
+ });
61
+ describe("default_tool", () => {
62
+ let mockBus;
63
+ let mockIdGenerator;
64
+ const log = create_logger();
65
+ let context;
66
+ beforeEach(() => {
67
+ mockBus = {
68
+ send_to_extension: jest.fn(),
69
+ on_reply_from_extension: jest.fn((_, callback) => {
70
+ callback({ test: "data" });
71
+ }),
72
+ };
73
+ mockIdGenerator = {
74
+ generate: jest.fn().mockReturnValue("789"),
75
+ };
76
+ context = {
77
+ bus: mockBus,
78
+ id_generator: mockIdGenerator,
79
+ log,
80
+ };
81
+ });
82
+ it("should create a tool that returns JSON stringified response", async () => {
83
+ const toolName = "default-tool";
84
+ const tool = default_tool(toolName, context);
85
+ const result = await tool({}, {});
86
+ expect(result).toEqual({
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: JSON.stringify({ test: "data" }),
91
+ },
92
+ ],
93
+ });
94
+ });
95
+ it("should use the provided tool name in the channel", async () => {
96
+ const toolName = "custom-tool";
97
+ const tool = default_tool(toolName, context);
98
+ await tool({}, {});
99
+ expect(mockBus.send_to_extension).toHaveBeenCalledWith({
100
+ __event: toolName,
101
+ __request_id: "789",
102
+ });
103
+ });
104
+ });
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export const bus_request_stream = "BUS_REQUEST";
2
+ export const bus_reply_stream = "BUS_REPLY";
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "drawio-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "Provides Draw.io services to MCP Clients",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "drawio-mcp-server": "./build/index.js"
9
+ },
10
+ "files": [
11
+ "build"
12
+ ],
13
+ "keywords": [
14
+ "mcp",
15
+ "modelcontextprotocol",
16
+ "server",
17
+ "draw.io",
18
+ "drawio",
19
+ "diagrams.net",
20
+ "diagramsnet",
21
+ "diagram",
22
+ "claude"
23
+ ],
24
+ "author": "Ladislav Gazo",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "1.10.1",
28
+ "nanoid": "5.1.5",
29
+ "pino": "9.6.0",
30
+ "pino-pretty": "13.0.0",
31
+ "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.51.0",
32
+ "zod": "3.24.3"
33
+ },
34
+ "devDependencies": {
35
+ "@jest/globals": "29.7.0",
36
+ "@types/jest": "29.5.12",
37
+ "@types/node": "22.14.1",
38
+ "globals": "15.0.0",
39
+ "jest": "29.7.0",
40
+ "jest-environment-node": "29.7.0",
41
+ "prettier": "3.5.3",
42
+ "rimraf": "6.0.1",
43
+ "ts-jest": "29.3.2",
44
+ "typescript": "5.8.3"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc",
48
+ "ci": "pnpm install --frozen-lockfile",
49
+ "dev": "tsc --watch",
50
+ "inspect": "pnpx @modelcontextprotocol/inspector node build/index.js",
51
+ "lint": "tsc --noEmit",
52
+ "format": "prettier --write \"src/**/*.ts\"",
53
+ "format:check": "prettier --check \"src/**/*.ts\"",
54
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
55
+ "test:watch": "jest --watch",
56
+ "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
57
+ "clean": "rimraf build coverage .eslintcache",
58
+ "prebuild": "pnpm run clean"
59
+ }
60
+ }