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 ADDED
@@ -0,0 +1,75 @@
1
+ # BlockWerk MCP Server
2
+
3
+ [![npm version](https://img.shields.io/npm/v/blockwerk-mcp.svg)](https://www.npmjs.com/package/blockwerk-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- * npx @blockwerk/mcp --session bw-a1b2c3d4
10
- * npx @blockwerk/mcp --session bw-a1b2c3d4 --relay https://blockwerk.tech
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
- const SESSION_ID = getArg('--session') ?? process.env.BLOCKWERK_SESSION;
22
- const RELAY_BASE = (getArg('--relay') ?? process.env.BLOCKWERK_RELAY ?? 'https://blockwerk.tech').replace(/\/$/, '');
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('Error: session ID required.\n' +
26
- 'Usage: npx @blockwerk/mcp --session <session-id>\n\n' +
27
- 'Get your session ID from blockwerk.tech → AI panel → "Connect external AI".');
28
- process.exit(1);
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({ requestId, name, input }),
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
- return typeof data.result === 'string' ? data.result : JSON.stringify(data.result);
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.0',
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.array(z.object({
73
- type: z.enum(['add_block', 'connect_blocks', 'update_params', 'delete_block', 'tidy_layout']),
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.string().optional().describe('Temporary ID for this block, usable in the same batch'),
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.string().optional().describe('Block ID or handle for update_params / delete_block'),
83
- params: z.record(z.unknown()).optional().describe('Parameter key-value pairs for update_params'),
84
- })).describe('Ordered list of commands to execute'),
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: [{ type: 'text', text: await callBlockWerk('connect_blocks', { fromBlockId, fromPortId, toBlockId, toPortId }) }],
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] Connected — session: ${SESSION_ID}, relay: ${RELAY_URL}`);
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.0",
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
- * npx @blockwerk/mcp --session bw-a1b2c3d4
10
- * npx @blockwerk/mcp --session bw-a1b2c3d4 --relay https://blockwerk.tech
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
- const SESSION_ID = getArg('--session') ?? process.env.BLOCKWERK_SESSION;
26
- const RELAY_BASE = (getArg('--relay') ?? process.env.BLOCKWERK_RELAY ?? 'https://blockwerk.tech').replace(/\/$/, '');
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
- 'Error: session ID required.\n' +
32
- 'Usage: npx @blockwerk/mcp --session <session-id>\n\n' +
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
- process.exit(1);
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({ requestId, name, input }),
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?: unknown };
160
+ const data = (await pollRes.json()) as { pending?: boolean; result?: string };
70
161
 
71
- if (!data.pending) {
72
- return typeof data.result === 'string' ? data.result : JSON.stringify(data.result);
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.0',
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.array(z.object({
115
- type: z.enum(['add_block', 'connect_blocks', 'update_params', 'delete_block', 'tidy_layout']),
116
- blockType: z.string().optional().describe('Block type for add_block, e.g. PIDController'),
117
- x: z.number().optional().describe('X position in pixels'),
118
- y: z.number().optional().describe('Y position in pixels'),
119
- handle: z.string().optional().describe('Temporary ID for this block, usable in the same batch'),
120
- fromBlockId: z.string().optional().describe('Block ID or handle for connect_blocks'),
121
- fromPortId: z.string().optional().describe('Output port, e.g. "out"'),
122
- toBlockId: z.string().optional().describe('Block ID or handle for connect_blocks'),
123
- toPortId: z.string().optional().describe('Input port, e.g. "in"'),
124
- blockId: z.string().optional().describe('Block ID or handle for update_params / delete_block'),
125
- params: z.record(z.unknown()).optional().describe('Parameter key-value pairs for update_params'),
126
- })).describe('Ordered list of commands to execute'),
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: [{ type: 'text', text: await callBlockWerk('connect_blocks', { fromBlockId, fromPortId, toBlockId, toPortId }) }],
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] Connected — session: ${SESSION_ID}, relay: ${RELAY_URL}`);
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); });