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 +211 -0
- package/build/emitter_bus.js +23 -0
- package/build/emitter_bus.test.js +65 -0
- package/build/events.js +14 -0
- package/build/events.test.js +74 -0
- package/build/index.js +184 -0
- package/build/mcp_console_logger.js +10 -0
- package/build/mcp_console_logger.test.js +51 -0
- package/build/nanoid_id_generator.js +6 -0
- package/build/nanoid_id_generator.test.js +26 -0
- package/build/standard_console_logger.js +10 -0
- package/build/tool.js +40 -0
- package/build/tool.test.js +104 -0
- package/build/types.js +2 -0
- package/package.json +60 -0
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
|
+
});
|
package/build/events.js
ADDED
|
@@ -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,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,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
|
+
});
|
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
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
|
+
}
|