blockwerk-mcp 0.1.0 → 0.2.1
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 +75 -0
- package/dist/index.js +209 -21
- package/package.json +22 -1
- package/src/index.ts +310 -41
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# BlockWerk MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/blockwerk-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
**BlockWerk MCP Server** bridges external AI clients (like Claude Desktop or Cursor) to a live BlockWerk browser tab. This allows an AI to observe and manipulate your block diagrams in real-time.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 🚀 Features
|
|
11
|
+
|
|
12
|
+
- **Read Canvas**: The AI can see all blocks, connections, and parameters.
|
|
13
|
+
- **Manipulate Canvas**: Add blocks, connect ports, update parameters, and delete blocks.
|
|
14
|
+
- **Simulation Control**: Run simulations, perform frequency sweeps (Bode plots), and analyze system topology.
|
|
15
|
+
- **Layout Engine**: Automatic layout tidying.
|
|
16
|
+
|
|
17
|
+
## 📦 Installation
|
|
18
|
+
|
|
19
|
+
You can run it directly via `npx`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx blockwerk-mcp --session <YOUR_SESSION_ID>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install it globally:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g blockwerk-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 🛠️ Setup
|
|
32
|
+
|
|
33
|
+
### 1. Get your Session ID
|
|
34
|
+
1. Open [blockwerk.tech](https://blockwerk.tech).
|
|
35
|
+
2. Click on the **AI Assistant** panel (right side).
|
|
36
|
+
3. Select **Connect External AI**.
|
|
37
|
+
4. Copy the provided **Session ID** (e.g., `bw-a1b2c3d4`).
|
|
38
|
+
|
|
39
|
+
### 2. Configure your Client
|
|
40
|
+
|
|
41
|
+
#### Claude Desktop
|
|
42
|
+
Add this to your `claude_desktop_config.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"blockwerk": {
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["-y", "blockwerk-mcp", "--session", "YOUR_SESSION_ID"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
#### Cursor
|
|
56
|
+
1. Go to **Settings > Models > MCP**.
|
|
57
|
+
2. Add a new MCP server:
|
|
58
|
+
- **Name**: BlockWerk
|
|
59
|
+
- **Type**: command
|
|
60
|
+
- **Command**: `npx -y blockwerk-mcp --session YOUR_SESSION_ID`
|
|
61
|
+
|
|
62
|
+
## 👨💻 Development
|
|
63
|
+
|
|
64
|
+
If you want to contribute or run from source:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/blockwerk/blockwerk
|
|
68
|
+
cd ui/blockwerk/projects/mcp-server
|
|
69
|
+
npm install
|
|
70
|
+
npm run build
|
|
71
|
+
npm run dev -- --session <YOUR_SESSION_ID>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 📄 License
|
|
75
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -6,36 +6,105 @@
|
|
|
6
6
|
* browser tab via the Netlify relay.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* # Path based (local dev)
|
|
10
|
+
* node projects/mcp-server/dist/index.js --session bw-a1b2c3d4
|
|
11
|
+
* npx tsx projects/mcp-server/src/index.ts --session bw-a1b2c3d4
|
|
12
|
+
*
|
|
13
|
+
* # npm based (after publishing)
|
|
14
|
+
* npx blockwerk-mcp --session bw-a1b2c3d4
|
|
11
15
|
*/
|
|
12
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
17
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
18
|
import { z } from 'zod';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { promisify } from 'node:util';
|
|
21
|
+
import { PostHog } from 'posthog-node';
|
|
22
|
+
const posthog = new PostHog(process.env.POSTHOG_API_KEY ?? '', {
|
|
23
|
+
host: process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com',
|
|
24
|
+
});
|
|
25
|
+
const pbkdf2 = promisify(crypto.pbkdf2);
|
|
15
26
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
16
27
|
const args = process.argv.slice(2);
|
|
17
28
|
const getArg = (flag) => {
|
|
18
29
|
const i = args.indexOf(flag);
|
|
19
30
|
return i !== -1 ? args[i + 1] : undefined;
|
|
20
31
|
};
|
|
21
|
-
|
|
22
|
-
const RELAY_BASE = (getArg('--relay') ??
|
|
32
|
+
let SESSION_ID = getArg('--session') ?? process.env.BLOCKWERK_SESSION;
|
|
33
|
+
const RELAY_BASE = (getArg('--relay') ??
|
|
34
|
+
process.env.BLOCKWERK_RELAY ??
|
|
35
|
+
'http://localhost:5173').replace(/\/$/, '');
|
|
23
36
|
const RELAY_URL = `${RELAY_BASE}/api/mcp-relay`;
|
|
24
37
|
if (!SESSION_ID) {
|
|
25
|
-
console.error('
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
console.error('[BlockWerk MCP] Warning: No session ID provided at startup.\n' +
|
|
39
|
+
'Use the "configure_bridge" tool to set the session ID at runtime.');
|
|
40
|
+
}
|
|
41
|
+
console.error(`[BlockWerk MCP] Connecting to relay: ${RELAY_URL}`);
|
|
42
|
+
// ── Cryptography ─────────────────────────────────────────────────────────────
|
|
43
|
+
const ITERATIONS = 100000;
|
|
44
|
+
const KEY_LEN = 32; // AES-256
|
|
45
|
+
/**
|
|
46
|
+
* Derives an AES-256 key from the session ID using PBKDF2.
|
|
47
|
+
* Uses a per-session salt (first 16 bytes of session ID hashed) to prevent
|
|
48
|
+
* rainbow table attacks against the static salt.
|
|
49
|
+
*/
|
|
50
|
+
async function deriveKey(password, saltHex) {
|
|
51
|
+
const salt = saltHex ? Buffer.from(saltHex, 'hex') : crypto.randomBytes(16);
|
|
52
|
+
const key = await pbkdf2(password, salt, ITERATIONS, KEY_LEN, 'sha256');
|
|
53
|
+
return { key, saltHex: salt.toString('hex') };
|
|
54
|
+
}
|
|
55
|
+
async function encryptData(data, sessionId) {
|
|
56
|
+
const { key, saltHex } = await deriveKey(sessionId);
|
|
57
|
+
const iv = crypto.randomBytes(12);
|
|
58
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
59
|
+
const buffer = Buffer.from(JSON.stringify(data));
|
|
60
|
+
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
61
|
+
const tag = cipher.getAuthTag();
|
|
62
|
+
// Format: salt:iv:ciphertext+tag (all hex/base64)
|
|
63
|
+
const ivHex = iv.toString('hex');
|
|
64
|
+
const ciphertextBase64 = Buffer.concat([encrypted, tag]).toString('base64');
|
|
65
|
+
return `${saltHex}:${ivHex}:${ciphertextBase64}`;
|
|
66
|
+
}
|
|
67
|
+
async function decryptData(encryptedString, sessionId) {
|
|
68
|
+
try {
|
|
69
|
+
const parts = encryptedString.split(':');
|
|
70
|
+
if (parts.length < 3)
|
|
71
|
+
return null;
|
|
72
|
+
const saltHex = parts[0];
|
|
73
|
+
const ivHex = parts[1];
|
|
74
|
+
const ciphertextBase64 = parts[2];
|
|
75
|
+
const { key } = await deriveKey(sessionId, saltHex);
|
|
76
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
77
|
+
const combined = Buffer.from(ciphertextBase64, 'base64');
|
|
78
|
+
const ciphertext = combined.subarray(0, combined.length - 16);
|
|
79
|
+
const tag = combined.subarray(combined.length - 16);
|
|
80
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
81
|
+
decipher.setAuthTag(tag);
|
|
82
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
83
|
+
return JSON.parse(decrypted.toString());
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
console.error('[MCP Crypto] Decryption failed:', e);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
29
89
|
}
|
|
30
90
|
// ── Relay communication ───────────────────────────────────────────────────────
|
|
31
91
|
const MAX_WAIT_MS = 60000; // total wait before giving up
|
|
32
92
|
async function callBlockWerk(name, input) {
|
|
93
|
+
if (!SESSION_ID) {
|
|
94
|
+
throw new Error('Session ID not configured. Please provide your BlockWerk Session ID first using the "configure_bridge" tool.');
|
|
95
|
+
}
|
|
96
|
+
posthog.capture({
|
|
97
|
+
distinctId: SESSION_ID,
|
|
98
|
+
event: 'mcp_tool_called',
|
|
99
|
+
properties: { tool: name },
|
|
100
|
+
});
|
|
33
101
|
const requestId = crypto.randomUUID();
|
|
34
|
-
// 1. Push command to relay
|
|
102
|
+
// 1. Push command to relay (ENCRYPTED)
|
|
103
|
+
const encryptedCmd = await encryptData({ requestId, name, input }, SESSION_ID);
|
|
35
104
|
const pushRes = await fetch(`${RELAY_URL}?session=${SESSION_ID}&type=command`, {
|
|
36
105
|
method: 'POST',
|
|
37
106
|
headers: { 'Content-Type': 'application/json' },
|
|
38
|
-
body: JSON.stringify(
|
|
107
|
+
body: JSON.stringify(encryptedCmd), // Relay accepts string as raw body or JSON string
|
|
39
108
|
});
|
|
40
109
|
if (!pushRes.ok) {
|
|
41
110
|
throw new Error(`Relay rejected command: ${pushRes.status} ${await pushRes.text()}`);
|
|
@@ -49,8 +118,12 @@ async function callBlockWerk(name, input) {
|
|
|
49
118
|
throw new Error(`Relay poll failed: ${pollRes.status}`);
|
|
50
119
|
}
|
|
51
120
|
const data = (await pollRes.json());
|
|
52
|
-
if (!data.pending) {
|
|
53
|
-
|
|
121
|
+
if (!data.pending && data.result) {
|
|
122
|
+
// DECRYPT Result
|
|
123
|
+
const decryptedResult = await decryptData(data.result, SESSION_ID);
|
|
124
|
+
return typeof decryptedResult === 'string'
|
|
125
|
+
? decryptedResult
|
|
126
|
+
: JSON.stringify(decryptedResult);
|
|
54
127
|
}
|
|
55
128
|
// pending: true — relay timed out but result not yet ready, retry immediately
|
|
56
129
|
}
|
|
@@ -59,9 +132,26 @@ async function callBlockWerk(name, input) {
|
|
|
59
132
|
// ── MCP server ────────────────────────────────────────────────────────────────
|
|
60
133
|
const server = new McpServer({
|
|
61
134
|
name: 'blockwerk',
|
|
62
|
-
version: '0.1
|
|
135
|
+
version: '0.2.1',
|
|
63
136
|
});
|
|
64
137
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
138
|
+
server.tool('configure_bridge', 'Connects this AI assistant to a specific BlockWerk browser session.', {
|
|
139
|
+
sessionId: z.string().describe('The Session ID from the BlockWerk UI (e.g. bw-a1b2c3d4)'),
|
|
140
|
+
}, async ({ sessionId }) => {
|
|
141
|
+
SESSION_ID = sessionId;
|
|
142
|
+
posthog.capture({
|
|
143
|
+
distinctId: SESSION_ID,
|
|
144
|
+
event: 'mcp_bridge_configured',
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: `Bridge configured successfully! Now connected to session: ${SESSION_ID}`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
});
|
|
65
155
|
server.tool('describe_canvas', 'Returns a structured text description of all blocks and connections on the BlockWerk canvas.', {}, async () => ({
|
|
66
156
|
content: [{ type: 'text', text: await callBlockWerk('describe_canvas', {}) }],
|
|
67
157
|
}));
|
|
@@ -69,19 +159,37 @@ server.tool('get_available_blocks', 'Lists all available block types with their
|
|
|
69
159
|
content: [{ type: 'text', text: await callBlockWerk('get_available_blocks', {}) }],
|
|
70
160
|
}));
|
|
71
161
|
server.tool('batch_commands', 'Executes multiple canvas operations in a single round-trip. Use handles (temporary IDs) to reference newly added blocks within the same batch. This is the preferred tool for building diagrams.', {
|
|
72
|
-
commands: z
|
|
73
|
-
|
|
162
|
+
commands: z
|
|
163
|
+
.array(z.object({
|
|
164
|
+
type: z.enum([
|
|
165
|
+
'add_block',
|
|
166
|
+
'connect_blocks',
|
|
167
|
+
'update_params',
|
|
168
|
+
'delete_block',
|
|
169
|
+
'tidy_layout',
|
|
170
|
+
'clear_canvas',
|
|
171
|
+
]),
|
|
74
172
|
blockType: z.string().optional().describe('Block type for add_block, e.g. PIDController'),
|
|
75
173
|
x: z.number().optional().describe('X position in pixels'),
|
|
76
174
|
y: z.number().optional().describe('Y position in pixels'),
|
|
77
|
-
handle: z
|
|
175
|
+
handle: z
|
|
176
|
+
.string()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe('Temporary ID for this block, usable in the same batch'),
|
|
78
179
|
fromBlockId: z.string().optional().describe('Block ID or handle for connect_blocks'),
|
|
79
180
|
fromPortId: z.string().optional().describe('Output port, e.g. "out"'),
|
|
80
181
|
toBlockId: z.string().optional().describe('Block ID or handle for connect_blocks'),
|
|
81
182
|
toPortId: z.string().optional().describe('Input port, e.g. "in"'),
|
|
82
|
-
blockId: z
|
|
83
|
-
|
|
84
|
-
|
|
183
|
+
blockId: z
|
|
184
|
+
.string()
|
|
185
|
+
.optional()
|
|
186
|
+
.describe('Block ID or handle for update_params / delete_block'),
|
|
187
|
+
params: z
|
|
188
|
+
.record(z.unknown())
|
|
189
|
+
.optional()
|
|
190
|
+
.describe('Parameter key-value pairs for update_params'),
|
|
191
|
+
}))
|
|
192
|
+
.describe('Ordered list of commands to execute'),
|
|
85
193
|
}, async ({ commands }) => ({
|
|
86
194
|
content: [{ type: 'text', text: await callBlockWerk('batch_commands', { commands }) }],
|
|
87
195
|
}));
|
|
@@ -104,7 +212,17 @@ server.tool('connect_blocks', 'Connects an output port of one block to an input
|
|
|
104
212
|
toBlockId: z.string().describe('Target block ID'),
|
|
105
213
|
toPortId: z.string().describe('Input port name, e.g. "in"'),
|
|
106
214
|
}, async ({ fromBlockId, fromPortId, toBlockId, toPortId }) => ({
|
|
107
|
-
content: [
|
|
215
|
+
content: [
|
|
216
|
+
{
|
|
217
|
+
type: 'text',
|
|
218
|
+
text: await callBlockWerk('connect_blocks', {
|
|
219
|
+
fromBlockId,
|
|
220
|
+
fromPortId,
|
|
221
|
+
toBlockId,
|
|
222
|
+
toPortId,
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
],
|
|
108
226
|
}));
|
|
109
227
|
server.tool('update_params', 'Updates parameters of a block.', {
|
|
110
228
|
blockId: z.string().describe('Block ID to update'),
|
|
@@ -122,7 +240,77 @@ server.tool('load_diagram', 'Loads a complete BlockWerk project from a JSON stri
|
|
|
122
240
|
}, async ({ json }) => ({
|
|
123
241
|
content: [{ type: 'text', text: await callBlockWerk('load_diagram', { json }) }],
|
|
124
242
|
}));
|
|
243
|
+
server.tool('get_signal_data', 'Retrieves time-series data (time and values) for a specific block and channel. Channel 0 is usually the primary signal.', {
|
|
244
|
+
blockId: z.string().describe('The ID of the block to inspect (e.g. Scope, UPlotScope)'),
|
|
245
|
+
channel: z.number().int().nonnegative().optional().describe('Channel index, default is 0'),
|
|
246
|
+
}, async ({ blockId, channel }) => ({
|
|
247
|
+
content: [{ type: 'text', text: await callBlockWerk('getSignalData', { blockId, channel }) }],
|
|
248
|
+
}));
|
|
249
|
+
server.tool('calculate_metrics', 'Calculates step response performance metrics (overshoot, rise time, settling time) for a specific block.', {
|
|
250
|
+
blockId: z.string().describe('The ID of the block to analyze'),
|
|
251
|
+
setpoint: z.number().finite().optional().describe('The target setpoint value (default 1.0)'),
|
|
252
|
+
}, async ({ blockId, setpoint }) => ({
|
|
253
|
+
content: [
|
|
254
|
+
{ type: 'text', text: await callBlockWerk('calculateMetrics', { blockId, setpoint }) },
|
|
255
|
+
],
|
|
256
|
+
}));
|
|
257
|
+
server.tool('analyze_topology', 'Analyzes the diagram topology to detect feedback loops, cycles, and connectivity issues.', {}, async () => ({
|
|
258
|
+
content: [{ type: 'text', text: await callBlockWerk('analyzeTopology', {}) }],
|
|
259
|
+
}));
|
|
260
|
+
server.tool('get_engine_status', 'Returns the current status of the simulation engine (Running, Idle, Error), simulation progress, and identifies any algebraic loops.', {}, async () => ({
|
|
261
|
+
content: [{ type: 'text', text: await callBlockWerk('get_engine_status', {}) }],
|
|
262
|
+
}));
|
|
263
|
+
server.tool('get_bode_data', 'Performs a frequency sweep analysis to generate Bode plot data (magnitude and phase) for a system.', {
|
|
264
|
+
inputBlockId: z
|
|
265
|
+
.string()
|
|
266
|
+
.describe('The ID of the block providing the sine excitation (e.g. SineWave)'),
|
|
267
|
+
outputBlockId: z
|
|
268
|
+
.string()
|
|
269
|
+
.describe('The ID of the block to measure (e.g. TransferFunction or Scope)'),
|
|
270
|
+
startFreq: z.number().optional().describe('Start frequency in rad/s (default 0.1)'),
|
|
271
|
+
endFreq: z.number().optional().describe('End frequency in rad/s (default 100)'),
|
|
272
|
+
pointsPerDecade: z
|
|
273
|
+
.number()
|
|
274
|
+
.int()
|
|
275
|
+
.optional()
|
|
276
|
+
.describe('Resolution in points per decade (default 10)'),
|
|
277
|
+
}, async (input) => ({
|
|
278
|
+
content: [{ type: 'text', text: await callBlockWerk('get_bode_data', input) }],
|
|
279
|
+
}));
|
|
280
|
+
server.tool('get_fft_data', 'Calculates the Fast Fourier Transform (FFT) for a specific signal to analyze its frequency spectrum.', {
|
|
281
|
+
blockId: z.string().describe('The ID of the block to analyze'),
|
|
282
|
+
channel: z.number().int().nonnegative().optional().describe('Channel index (default 0)'),
|
|
283
|
+
}, async ({ blockId, channel }) => ({
|
|
284
|
+
content: [{ type: 'text', text: await callBlockWerk('get_fft_data', { blockId, channel }) }],
|
|
285
|
+
}));
|
|
286
|
+
server.tool('undo_last_ai_action', 'Undoes the last AI action, restoring the canvas to its state before the tool was executed.', {}, async () => ({
|
|
287
|
+
content: [{ type: 'text', text: await callBlockWerk('undo_last_ai_action', {}) }],
|
|
288
|
+
}));
|
|
289
|
+
server.tool('redo_last_ai_action', 'Redoes the last undone AI action.', {}, async () => ({
|
|
290
|
+
content: [{ type: 'text', text: await callBlockWerk('redo_last_ai_action', {}) }],
|
|
291
|
+
}));
|
|
292
|
+
server.tool('get_ai_history', 'Returns a summary of recent AI actions with their results and timestamps.', {
|
|
293
|
+
limit: z
|
|
294
|
+
.number()
|
|
295
|
+
.int()
|
|
296
|
+
.positive()
|
|
297
|
+
.optional()
|
|
298
|
+
.describe('Number of recent entries to return (default 10)'),
|
|
299
|
+
}, async ({ limit }) => ({
|
|
300
|
+
content: [{ type: 'text', text: await callBlockWerk('get_ai_history', { limit }) }],
|
|
301
|
+
}));
|
|
125
302
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
126
303
|
const transport = new StdioServerTransport();
|
|
127
304
|
await server.connect(transport);
|
|
128
|
-
console.error(`[BlockWerk MCP]
|
|
305
|
+
console.error(`[BlockWerk MCP] Started — session: ${SESSION_ID || 'PENDING'}, relay: ${RELAY_URL}`);
|
|
306
|
+
posthog.capture({
|
|
307
|
+
distinctId: SESSION_ID ?? 'anonymous',
|
|
308
|
+
event: 'mcp_server_started',
|
|
309
|
+
properties: {
|
|
310
|
+
relay_url: RELAY_BASE,
|
|
311
|
+
has_session: !!SESSION_ID,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
process.on('exit', () => { posthog.shutdown(); });
|
|
315
|
+
process.on('SIGINT', async () => { await posthog.shutdown(); process.exit(0); });
|
|
316
|
+
process.on('SIGTERM', async () => { await posthog.shutdown(); process.exit(0); });
|
package/package.json
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blockwerk-mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "MCP server for BlockWerk — lets Claude Desktop and Cursor control a live BlockWerk browser tab",
|
|
5
|
+
"homepage": "https://blockwerk.tech",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/blockwerk/blockwerk.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/blockwerk/blockwerk/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"blockwerk",
|
|
16
|
+
"block-diagram",
|
|
17
|
+
"simulation",
|
|
18
|
+
"control",
|
|
19
|
+
"ai",
|
|
20
|
+
"claude",
|
|
21
|
+
"cursor"
|
|
22
|
+
],
|
|
23
|
+
"author": "BlockWerk Team",
|
|
24
|
+
"license": "MIT",
|
|
5
25
|
"type": "module",
|
|
6
26
|
"bin": {
|
|
7
27
|
"blockwerk-mcp": "dist/index.js"
|
|
@@ -13,6 +33,7 @@
|
|
|
13
33
|
},
|
|
14
34
|
"dependencies": {
|
|
15
35
|
"@modelcontextprotocol/sdk": "^1.15.0",
|
|
36
|
+
"posthog-node": "^5.28.11",
|
|
16
37
|
"zod": "^3.25.0"
|
|
17
38
|
},
|
|
18
39
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -6,13 +6,26 @@
|
|
|
6
6
|
* browser tab via the Netlify relay.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* # Path based (local dev)
|
|
10
|
+
* node projects/mcp-server/dist/index.js --session bw-a1b2c3d4
|
|
11
|
+
* npx tsx projects/mcp-server/src/index.ts --session bw-a1b2c3d4
|
|
12
|
+
*
|
|
13
|
+
* # npm based (after publishing)
|
|
14
|
+
* npx blockwerk-mcp --session bw-a1b2c3d4
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
14
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
19
|
import { z } from 'zod';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
import { PostHog } from 'posthog-node';
|
|
23
|
+
|
|
24
|
+
const posthog = new PostHog(process.env.POSTHOG_API_KEY ?? '', {
|
|
25
|
+
host: process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const pbkdf2 = promisify(crypto.pbkdf2);
|
|
16
29
|
|
|
17
30
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
18
31
|
|
|
@@ -22,17 +35,83 @@ const getArg = (flag: string) => {
|
|
|
22
35
|
return i !== -1 ? args[i + 1] : undefined;
|
|
23
36
|
};
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
const RELAY_BASE = (
|
|
38
|
+
let SESSION_ID = getArg('--session') ?? process.env.BLOCKWERK_SESSION;
|
|
39
|
+
const RELAY_BASE = (
|
|
40
|
+
getArg('--relay') ??
|
|
41
|
+
process.env.BLOCKWERK_RELAY ??
|
|
42
|
+
'http://localhost:5173'
|
|
43
|
+
).replace(/\/$/, '');
|
|
27
44
|
const RELAY_URL = `${RELAY_BASE}/api/mcp-relay`;
|
|
28
45
|
|
|
29
46
|
if (!SESSION_ID) {
|
|
30
47
|
console.error(
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
'Get your session ID from blockwerk.tech → AI panel → "Connect external AI".',
|
|
48
|
+
'[BlockWerk MCP] Warning: No session ID provided at startup.\n' +
|
|
49
|
+
'Use the "configure_bridge" tool to set the session ID at runtime.'
|
|
34
50
|
);
|
|
35
|
-
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.error(`[BlockWerk MCP] Connecting to relay: ${RELAY_URL}`);
|
|
54
|
+
|
|
55
|
+
// ── Cryptography ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const ITERATIONS = 100000;
|
|
58
|
+
const KEY_LEN = 32; // AES-256
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derives an AES-256 key from the session ID using PBKDF2.
|
|
62
|
+
* Uses a per-session salt (first 16 bytes of session ID hashed) to prevent
|
|
63
|
+
* rainbow table attacks against the static salt.
|
|
64
|
+
*/
|
|
65
|
+
async function deriveKey(
|
|
66
|
+
password: string,
|
|
67
|
+
saltHex?: string
|
|
68
|
+
): Promise<{ key: Buffer; saltHex: string }> {
|
|
69
|
+
const salt = saltHex ? Buffer.from(saltHex, 'hex') : crypto.randomBytes(16);
|
|
70
|
+
const key = await pbkdf2(password, salt, ITERATIONS, KEY_LEN, 'sha256');
|
|
71
|
+
return { key, saltHex: salt.toString('hex') };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function encryptData(data: unknown, sessionId: string): Promise<string> {
|
|
75
|
+
const { key, saltHex } = await deriveKey(sessionId);
|
|
76
|
+
const iv = crypto.randomBytes(12);
|
|
77
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
78
|
+
|
|
79
|
+
const buffer = Buffer.from(JSON.stringify(data));
|
|
80
|
+
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
81
|
+
const tag = cipher.getAuthTag();
|
|
82
|
+
|
|
83
|
+
// Format: salt:iv:ciphertext+tag (all hex/base64)
|
|
84
|
+
const ivHex = iv.toString('hex');
|
|
85
|
+
const ciphertextBase64 = Buffer.concat([encrypted, tag]).toString('base64');
|
|
86
|
+
|
|
87
|
+
return `${saltHex}:${ivHex}:${ciphertextBase64}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function decryptData(encryptedString: string, sessionId: string): Promise<unknown> {
|
|
91
|
+
try {
|
|
92
|
+
const parts = encryptedString.split(':');
|
|
93
|
+
if (parts.length < 3) return null;
|
|
94
|
+
|
|
95
|
+
const saltHex = parts[0];
|
|
96
|
+
const ivHex = parts[1];
|
|
97
|
+
const ciphertextBase64 = parts[2];
|
|
98
|
+
|
|
99
|
+
const { key } = await deriveKey(sessionId, saltHex);
|
|
100
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
101
|
+
const combined = Buffer.from(ciphertextBase64, 'base64');
|
|
102
|
+
|
|
103
|
+
const ciphertext = combined.subarray(0, combined.length - 16);
|
|
104
|
+
const tag = combined.subarray(combined.length - 16);
|
|
105
|
+
|
|
106
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
107
|
+
decipher.setAuthTag(tag);
|
|
108
|
+
|
|
109
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
110
|
+
return JSON.parse(decrypted.toString());
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error('[MCP Crypto] Decryption failed:', e);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
36
115
|
}
|
|
37
116
|
|
|
38
117
|
// ── Relay communication ───────────────────────────────────────────────────────
|
|
@@ -40,13 +119,25 @@ if (!SESSION_ID) {
|
|
|
40
119
|
const MAX_WAIT_MS = 60000; // total wait before giving up
|
|
41
120
|
|
|
42
121
|
async function callBlockWerk(name: string, input: Record<string, unknown>): Promise<string> {
|
|
122
|
+
if (!SESSION_ID) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Session ID not configured. Please provide your BlockWerk Session ID first using the "configure_bridge" tool.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
posthog.capture({
|
|
128
|
+
distinctId: SESSION_ID,
|
|
129
|
+
event: 'mcp_tool_called',
|
|
130
|
+
properties: { tool: name },
|
|
131
|
+
});
|
|
43
132
|
const requestId = crypto.randomUUID();
|
|
44
133
|
|
|
45
|
-
// 1. Push command to relay
|
|
134
|
+
// 1. Push command to relay (ENCRYPTED)
|
|
135
|
+
const encryptedCmd = await encryptData({ requestId, name, input }, SESSION_ID);
|
|
136
|
+
|
|
46
137
|
const pushRes = await fetch(`${RELAY_URL}?session=${SESSION_ID}&type=command`, {
|
|
47
138
|
method: 'POST',
|
|
48
139
|
headers: { 'Content-Type': 'application/json' },
|
|
49
|
-
body: JSON.stringify(
|
|
140
|
+
body: JSON.stringify(encryptedCmd), // Relay accepts string as raw body or JSON string
|
|
50
141
|
});
|
|
51
142
|
|
|
52
143
|
if (!pushRes.ok) {
|
|
@@ -59,24 +150,28 @@ async function callBlockWerk(name: string, input: Record<string, unknown>): Prom
|
|
|
59
150
|
|
|
60
151
|
while (Date.now() < deadline) {
|
|
61
152
|
const pollRes = await fetch(
|
|
62
|
-
`${RELAY_URL}?session=${SESSION_ID}&type=result&requestId=${requestId}
|
|
153
|
+
`${RELAY_URL}?session=${SESSION_ID}&type=result&requestId=${requestId}`
|
|
63
154
|
);
|
|
64
155
|
|
|
65
156
|
if (!pollRes.ok) {
|
|
66
157
|
throw new Error(`Relay poll failed: ${pollRes.status}`);
|
|
67
158
|
}
|
|
68
159
|
|
|
69
|
-
const data = (await pollRes.json()) as { pending?: boolean; result?:
|
|
160
|
+
const data = (await pollRes.json()) as { pending?: boolean; result?: string };
|
|
70
161
|
|
|
71
|
-
if (!data.pending) {
|
|
72
|
-
|
|
162
|
+
if (!data.pending && data.result) {
|
|
163
|
+
// DECRYPT Result
|
|
164
|
+
const decryptedResult = await decryptData(data.result, SESSION_ID);
|
|
165
|
+
return typeof decryptedResult === 'string'
|
|
166
|
+
? decryptedResult
|
|
167
|
+
: JSON.stringify(decryptedResult);
|
|
73
168
|
}
|
|
74
169
|
|
|
75
170
|
// pending: true — relay timed out but result not yet ready, retry immediately
|
|
76
171
|
}
|
|
77
172
|
|
|
78
173
|
throw new Error(
|
|
79
|
-
'BlockWerk did not respond within 60 seconds. Is the browser tab open and the MCP bridge active?'
|
|
174
|
+
'BlockWerk did not respond within 60 seconds. Is the browser tab open and the MCP bridge active?'
|
|
80
175
|
);
|
|
81
176
|
}
|
|
82
177
|
|
|
@@ -84,18 +179,41 @@ async function callBlockWerk(name: string, input: Record<string, unknown>): Prom
|
|
|
84
179
|
|
|
85
180
|
const server = new McpServer({
|
|
86
181
|
name: 'blockwerk',
|
|
87
|
-
version: '0.1
|
|
182
|
+
version: '0.2.1',
|
|
88
183
|
});
|
|
89
184
|
|
|
90
185
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
91
186
|
|
|
187
|
+
server.tool(
|
|
188
|
+
'configure_bridge',
|
|
189
|
+
'Connects this AI assistant to a specific BlockWerk browser session.',
|
|
190
|
+
{
|
|
191
|
+
sessionId: z.string().describe('The Session ID from the BlockWerk UI (e.g. bw-a1b2c3d4)'),
|
|
192
|
+
},
|
|
193
|
+
async ({ sessionId }) => {
|
|
194
|
+
SESSION_ID = sessionId;
|
|
195
|
+
posthog.capture({
|
|
196
|
+
distinctId: SESSION_ID,
|
|
197
|
+
event: 'mcp_bridge_configured',
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
content: [
|
|
201
|
+
{
|
|
202
|
+
type: 'text',
|
|
203
|
+
text: `Bridge configured successfully! Now connected to session: ${SESSION_ID}`,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
92
210
|
server.tool(
|
|
93
211
|
'describe_canvas',
|
|
94
212
|
'Returns a structured text description of all blocks and connections on the BlockWerk canvas.',
|
|
95
213
|
{},
|
|
96
214
|
async () => ({
|
|
97
215
|
content: [{ type: 'text', text: await callBlockWerk('describe_canvas', {}) }],
|
|
98
|
-
})
|
|
216
|
+
})
|
|
99
217
|
);
|
|
100
218
|
|
|
101
219
|
server.tool(
|
|
@@ -104,30 +222,50 @@ server.tool(
|
|
|
104
222
|
{},
|
|
105
223
|
async () => ({
|
|
106
224
|
content: [{ type: 'text', text: await callBlockWerk('get_available_blocks', {}) }],
|
|
107
|
-
})
|
|
225
|
+
})
|
|
108
226
|
);
|
|
109
227
|
|
|
110
228
|
server.tool(
|
|
111
229
|
'batch_commands',
|
|
112
230
|
'Executes multiple canvas operations in a single round-trip. Use handles (temporary IDs) to reference newly added blocks within the same batch. This is the preferred tool for building diagrams.',
|
|
113
231
|
{
|
|
114
|
-
commands: z
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
232
|
+
commands: z
|
|
233
|
+
.array(
|
|
234
|
+
z.object({
|
|
235
|
+
type: z.enum([
|
|
236
|
+
'add_block',
|
|
237
|
+
'connect_blocks',
|
|
238
|
+
'update_params',
|
|
239
|
+
'delete_block',
|
|
240
|
+
'tidy_layout',
|
|
241
|
+
'clear_canvas',
|
|
242
|
+
]),
|
|
243
|
+
blockType: z.string().optional().describe('Block type for add_block, e.g. PIDController'),
|
|
244
|
+
x: z.number().optional().describe('X position in pixels'),
|
|
245
|
+
y: z.number().optional().describe('Y position in pixels'),
|
|
246
|
+
handle: z
|
|
247
|
+
.string()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe('Temporary ID for this block, usable in the same batch'),
|
|
250
|
+
fromBlockId: z.string().optional().describe('Block ID or handle for connect_blocks'),
|
|
251
|
+
fromPortId: z.string().optional().describe('Output port, e.g. "out"'),
|
|
252
|
+
toBlockId: z.string().optional().describe('Block ID or handle for connect_blocks'),
|
|
253
|
+
toPortId: z.string().optional().describe('Input port, e.g. "in"'),
|
|
254
|
+
blockId: z
|
|
255
|
+
.string()
|
|
256
|
+
.optional()
|
|
257
|
+
.describe('Block ID or handle for update_params / delete_block'),
|
|
258
|
+
params: z
|
|
259
|
+
.record(z.unknown())
|
|
260
|
+
.optional()
|
|
261
|
+
.describe('Parameter key-value pairs for update_params'),
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
.describe('Ordered list of commands to execute'),
|
|
127
265
|
},
|
|
128
266
|
async ({ commands }) => ({
|
|
129
267
|
content: [{ type: 'text', text: await callBlockWerk('batch_commands', { commands }) }],
|
|
130
|
-
})
|
|
268
|
+
})
|
|
131
269
|
);
|
|
132
270
|
|
|
133
271
|
server.tool(
|
|
@@ -136,7 +274,7 @@ server.tool(
|
|
|
136
274
|
{},
|
|
137
275
|
async () => ({
|
|
138
276
|
content: [{ type: 'text', text: await callBlockWerk('tidy_layout', {}) }],
|
|
139
|
-
})
|
|
277
|
+
})
|
|
140
278
|
);
|
|
141
279
|
|
|
142
280
|
server.tool(
|
|
@@ -145,7 +283,7 @@ server.tool(
|
|
|
145
283
|
{},
|
|
146
284
|
async () => ({
|
|
147
285
|
content: [{ type: 'text', text: await callBlockWerk('clear_canvas', {}) }],
|
|
148
|
-
})
|
|
286
|
+
})
|
|
149
287
|
);
|
|
150
288
|
|
|
151
289
|
server.tool(
|
|
@@ -158,7 +296,7 @@ server.tool(
|
|
|
158
296
|
},
|
|
159
297
|
async ({ type, x, y }) => ({
|
|
160
298
|
content: [{ type: 'text', text: await callBlockWerk('add_block', { type, x, y }) }],
|
|
161
|
-
})
|
|
299
|
+
})
|
|
162
300
|
);
|
|
163
301
|
|
|
164
302
|
server.tool(
|
|
@@ -171,8 +309,18 @@ server.tool(
|
|
|
171
309
|
toPortId: z.string().describe('Input port name, e.g. "in"'),
|
|
172
310
|
},
|
|
173
311
|
async ({ fromBlockId, fromPortId, toBlockId, toPortId }) => ({
|
|
174
|
-
content: [
|
|
175
|
-
|
|
312
|
+
content: [
|
|
313
|
+
{
|
|
314
|
+
type: 'text',
|
|
315
|
+
text: await callBlockWerk('connect_blocks', {
|
|
316
|
+
fromBlockId,
|
|
317
|
+
fromPortId,
|
|
318
|
+
toBlockId,
|
|
319
|
+
toPortId,
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
})
|
|
176
324
|
);
|
|
177
325
|
|
|
178
326
|
server.tool(
|
|
@@ -184,7 +332,7 @@ server.tool(
|
|
|
184
332
|
},
|
|
185
333
|
async ({ blockId, params }) => ({
|
|
186
334
|
content: [{ type: 'text', text: await callBlockWerk('update_params', { blockId, params }) }],
|
|
187
|
-
})
|
|
335
|
+
})
|
|
188
336
|
);
|
|
189
337
|
|
|
190
338
|
server.tool(
|
|
@@ -195,7 +343,7 @@ server.tool(
|
|
|
195
343
|
},
|
|
196
344
|
async ({ blockId }) => ({
|
|
197
345
|
content: [{ type: 'text', text: await callBlockWerk('delete_block', { blockId }) }],
|
|
198
|
-
})
|
|
346
|
+
})
|
|
199
347
|
);
|
|
200
348
|
|
|
201
349
|
server.tool(
|
|
@@ -206,11 +354,132 @@ server.tool(
|
|
|
206
354
|
},
|
|
207
355
|
async ({ json }) => ({
|
|
208
356
|
content: [{ type: 'text', text: await callBlockWerk('load_diagram', { json }) }],
|
|
209
|
-
})
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
server.tool(
|
|
361
|
+
'get_signal_data',
|
|
362
|
+
'Retrieves time-series data (time and values) for a specific block and channel. Channel 0 is usually the primary signal.',
|
|
363
|
+
{
|
|
364
|
+
blockId: z.string().describe('The ID of the block to inspect (e.g. Scope, UPlotScope)'),
|
|
365
|
+
channel: z.number().int().nonnegative().optional().describe('Channel index, default is 0'),
|
|
366
|
+
},
|
|
367
|
+
async ({ blockId, channel }) => ({
|
|
368
|
+
content: [{ type: 'text', text: await callBlockWerk('getSignalData', { blockId, channel }) }],
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
server.tool(
|
|
373
|
+
'calculate_metrics',
|
|
374
|
+
'Calculates step response performance metrics (overshoot, rise time, settling time) for a specific block.',
|
|
375
|
+
{
|
|
376
|
+
blockId: z.string().describe('The ID of the block to analyze'),
|
|
377
|
+
setpoint: z.number().finite().optional().describe('The target setpoint value (default 1.0)'),
|
|
378
|
+
},
|
|
379
|
+
async ({ blockId, setpoint }) => ({
|
|
380
|
+
content: [
|
|
381
|
+
{ type: 'text', text: await callBlockWerk('calculateMetrics', { blockId, setpoint }) },
|
|
382
|
+
],
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
server.tool(
|
|
387
|
+
'analyze_topology',
|
|
388
|
+
'Analyzes the diagram topology to detect feedback loops, cycles, and connectivity issues.',
|
|
389
|
+
{},
|
|
390
|
+
async () => ({
|
|
391
|
+
content: [{ type: 'text', text: await callBlockWerk('analyzeTopology', {}) }],
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
server.tool(
|
|
396
|
+
'get_engine_status',
|
|
397
|
+
'Returns the current status of the simulation engine (Running, Idle, Error), simulation progress, and identifies any algebraic loops.',
|
|
398
|
+
{},
|
|
399
|
+
async () => ({
|
|
400
|
+
content: [{ type: 'text', text: await callBlockWerk('get_engine_status', {}) }],
|
|
401
|
+
})
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
server.tool(
|
|
405
|
+
'get_bode_data',
|
|
406
|
+
'Performs a frequency sweep analysis to generate Bode plot data (magnitude and phase) for a system.',
|
|
407
|
+
{
|
|
408
|
+
inputBlockId: z
|
|
409
|
+
.string()
|
|
410
|
+
.describe('The ID of the block providing the sine excitation (e.g. SineWave)'),
|
|
411
|
+
outputBlockId: z
|
|
412
|
+
.string()
|
|
413
|
+
.describe('The ID of the block to measure (e.g. TransferFunction or Scope)'),
|
|
414
|
+
startFreq: z.number().optional().describe('Start frequency in rad/s (default 0.1)'),
|
|
415
|
+
endFreq: z.number().optional().describe('End frequency in rad/s (default 100)'),
|
|
416
|
+
pointsPerDecade: z
|
|
417
|
+
.number()
|
|
418
|
+
.int()
|
|
419
|
+
.optional()
|
|
420
|
+
.describe('Resolution in points per decade (default 10)'),
|
|
421
|
+
},
|
|
422
|
+
async (input) => ({
|
|
423
|
+
content: [{ type: 'text', text: await callBlockWerk('get_bode_data', input) }],
|
|
424
|
+
})
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
server.tool(
|
|
428
|
+
'get_fft_data',
|
|
429
|
+
'Calculates the Fast Fourier Transform (FFT) for a specific signal to analyze its frequency spectrum.',
|
|
430
|
+
{
|
|
431
|
+
blockId: z.string().describe('The ID of the block to analyze'),
|
|
432
|
+
channel: z.number().int().nonnegative().optional().describe('Channel index (default 0)'),
|
|
433
|
+
},
|
|
434
|
+
async ({ blockId, channel }) => ({
|
|
435
|
+
content: [{ type: 'text', text: await callBlockWerk('get_fft_data', { blockId, channel }) }],
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
server.tool(
|
|
440
|
+
'undo_last_ai_action',
|
|
441
|
+
'Undoes the last AI action, restoring the canvas to its state before the tool was executed.',
|
|
442
|
+
{},
|
|
443
|
+
async () => ({
|
|
444
|
+
content: [{ type: 'text', text: await callBlockWerk('undo_last_ai_action', {}) }],
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
server.tool('redo_last_ai_action', 'Redoes the last undone AI action.', {}, async () => ({
|
|
449
|
+
content: [{ type: 'text', text: await callBlockWerk('redo_last_ai_action', {}) }],
|
|
450
|
+
}));
|
|
451
|
+
|
|
452
|
+
server.tool(
|
|
453
|
+
'get_ai_history',
|
|
454
|
+
'Returns a summary of recent AI actions with their results and timestamps.',
|
|
455
|
+
{
|
|
456
|
+
limit: z
|
|
457
|
+
.number()
|
|
458
|
+
.int()
|
|
459
|
+
.positive()
|
|
460
|
+
.optional()
|
|
461
|
+
.describe('Number of recent entries to return (default 10)'),
|
|
462
|
+
},
|
|
463
|
+
async ({ limit }) => ({
|
|
464
|
+
content: [{ type: 'text', text: await callBlockWerk('get_ai_history', { limit }) }],
|
|
465
|
+
})
|
|
210
466
|
);
|
|
211
467
|
|
|
212
468
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
213
469
|
|
|
214
470
|
const transport = new StdioServerTransport();
|
|
215
471
|
await server.connect(transport);
|
|
216
|
-
console.error(`[BlockWerk MCP]
|
|
472
|
+
console.error(`[BlockWerk MCP] Started — session: ${SESSION_ID || 'PENDING'}, relay: ${RELAY_URL}`);
|
|
473
|
+
|
|
474
|
+
posthog.capture({
|
|
475
|
+
distinctId: SESSION_ID ?? 'anonymous',
|
|
476
|
+
event: 'mcp_server_started',
|
|
477
|
+
properties: {
|
|
478
|
+
relay_url: RELAY_BASE,
|
|
479
|
+
has_session: !!SESSION_ID,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
process.on('exit', () => { posthog.shutdown(); });
|
|
484
|
+
process.on('SIGINT', async () => { await posthog.shutdown(); process.exit(0); });
|
|
485
|
+
process.on('SIGTERM', async () => { await posthog.shutdown(); process.exit(0); });
|