blockwerk-mcp 0.1.1 → 0.2.2
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 +152 -8
- package/package.json +47 -26
- package/src/index.ts +222 -9
- package/tsconfig.json +13 -13
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,12 +6,23 @@
|
|
|
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) => {
|
|
@@ -21,24 +32,79 @@ const getArg = (flag) => {
|
|
|
21
32
|
let SESSION_ID = getArg('--session') ?? process.env.BLOCKWERK_SESSION;
|
|
22
33
|
const RELAY_BASE = (getArg('--relay') ??
|
|
23
34
|
process.env.BLOCKWERK_RELAY ??
|
|
24
|
-
'
|
|
35
|
+
'http://localhost:5173').replace(/\/$/, '');
|
|
25
36
|
const RELAY_URL = `${RELAY_BASE}/api/mcp-relay`;
|
|
26
37
|
if (!SESSION_ID) {
|
|
27
38
|
console.error('[BlockWerk MCP] Warning: No session ID provided at startup.\n' +
|
|
28
39
|
'Use the "configure_bridge" tool to set the session ID at runtime.');
|
|
29
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
|
+
}
|
|
89
|
+
}
|
|
30
90
|
// ── Relay communication ───────────────────────────────────────────────────────
|
|
31
91
|
const MAX_WAIT_MS = 60000; // total wait before giving up
|
|
32
92
|
async function callBlockWerk(name, input) {
|
|
33
93
|
if (!SESSION_ID) {
|
|
34
94
|
throw new Error('Session ID not configured. Please provide your BlockWerk Session ID first using the "configure_bridge" tool.');
|
|
35
95
|
}
|
|
96
|
+
posthog.capture({
|
|
97
|
+
distinctId: SESSION_ID,
|
|
98
|
+
event: 'mcp_tool_called',
|
|
99
|
+
properties: { tool: name },
|
|
100
|
+
});
|
|
36
101
|
const requestId = crypto.randomUUID();
|
|
37
|
-
// 1. Push command to relay
|
|
102
|
+
// 1. Push command to relay (ENCRYPTED)
|
|
103
|
+
const encryptedCmd = await encryptData({ requestId, name, input }, SESSION_ID);
|
|
38
104
|
const pushRes = await fetch(`${RELAY_URL}?session=${SESSION_ID}&type=command`, {
|
|
39
105
|
method: 'POST',
|
|
40
106
|
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
body: JSON.stringify(
|
|
107
|
+
body: JSON.stringify(encryptedCmd), // Relay accepts string as raw body or JSON string
|
|
42
108
|
});
|
|
43
109
|
if (!pushRes.ok) {
|
|
44
110
|
throw new Error(`Relay rejected command: ${pushRes.status} ${await pushRes.text()}`);
|
|
@@ -52,8 +118,12 @@ async function callBlockWerk(name, input) {
|
|
|
52
118
|
throw new Error(`Relay poll failed: ${pollRes.status}`);
|
|
53
119
|
}
|
|
54
120
|
const data = (await pollRes.json());
|
|
55
|
-
if (!data.pending) {
|
|
56
|
-
|
|
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);
|
|
57
127
|
}
|
|
58
128
|
// pending: true — relay timed out but result not yet ready, retry immediately
|
|
59
129
|
}
|
|
@@ -62,13 +132,17 @@ async function callBlockWerk(name, input) {
|
|
|
62
132
|
// ── MCP server ────────────────────────────────────────────────────────────────
|
|
63
133
|
const server = new McpServer({
|
|
64
134
|
name: 'blockwerk',
|
|
65
|
-
version: '0.1
|
|
135
|
+
version: '0.2.1',
|
|
66
136
|
});
|
|
67
137
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
68
138
|
server.tool('configure_bridge', 'Connects this AI assistant to a specific BlockWerk browser session.', {
|
|
69
139
|
sessionId: z.string().describe('The Session ID from the BlockWerk UI (e.g. bw-a1b2c3d4)'),
|
|
70
140
|
}, async ({ sessionId }) => {
|
|
71
141
|
SESSION_ID = sessionId;
|
|
142
|
+
posthog.capture({
|
|
143
|
+
distinctId: SESSION_ID,
|
|
144
|
+
event: 'mcp_bridge_configured',
|
|
145
|
+
});
|
|
72
146
|
return {
|
|
73
147
|
content: [
|
|
74
148
|
{
|
|
@@ -166,7 +240,77 @@ server.tool('load_diagram', 'Loads a complete BlockWerk project from a JSON stri
|
|
|
166
240
|
}, async ({ json }) => ({
|
|
167
241
|
content: [{ type: 'text', text: await callBlockWerk('load_diagram', { json }) }],
|
|
168
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
|
+
}));
|
|
169
302
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
170
303
|
const transport = new StdioServerTransport();
|
|
171
304
|
await server.connect(transport);
|
|
172
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,26 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "blockwerk-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for BlockWerk — lets Claude Desktop and Cursor control a live BlockWerk browser tab",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "blockwerk-mcp",
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"blockwerk-mcp": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"dev": "tsx src/index.ts",
|
|
32
|
+
"start": "node dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.15.0",
|
|
36
|
+
"posthog-node": "^5.28.11",
|
|
37
|
+
"zod": "^3.25.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"tsx": "^4.19.0",
|
|
42
|
+
"typescript": "~5.9.3"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
}
|
|
47
|
+
}
|
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
|
|
|
@@ -26,7 +39,7 @@ let SESSION_ID = getArg('--session') ?? process.env.BLOCKWERK_SESSION;
|
|
|
26
39
|
const RELAY_BASE = (
|
|
27
40
|
getArg('--relay') ??
|
|
28
41
|
process.env.BLOCKWERK_RELAY ??
|
|
29
|
-
'
|
|
42
|
+
'http://localhost:5173'
|
|
30
43
|
).replace(/\/$/, '');
|
|
31
44
|
const RELAY_URL = `${RELAY_BASE}/api/mcp-relay`;
|
|
32
45
|
|
|
@@ -37,6 +50,70 @@ if (!SESSION_ID) {
|
|
|
37
50
|
);
|
|
38
51
|
}
|
|
39
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
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
40
117
|
// ── Relay communication ───────────────────────────────────────────────────────
|
|
41
118
|
|
|
42
119
|
const MAX_WAIT_MS = 60000; // total wait before giving up
|
|
@@ -47,13 +124,20 @@ async function callBlockWerk(name: string, input: Record<string, unknown>): Prom
|
|
|
47
124
|
'Session ID not configured. Please provide your BlockWerk Session ID first using the "configure_bridge" tool.'
|
|
48
125
|
);
|
|
49
126
|
}
|
|
127
|
+
posthog.capture({
|
|
128
|
+
distinctId: SESSION_ID,
|
|
129
|
+
event: 'mcp_tool_called',
|
|
130
|
+
properties: { tool: name },
|
|
131
|
+
});
|
|
50
132
|
const requestId = crypto.randomUUID();
|
|
51
133
|
|
|
52
|
-
// 1. Push command to relay
|
|
134
|
+
// 1. Push command to relay (ENCRYPTED)
|
|
135
|
+
const encryptedCmd = await encryptData({ requestId, name, input }, SESSION_ID);
|
|
136
|
+
|
|
53
137
|
const pushRes = await fetch(`${RELAY_URL}?session=${SESSION_ID}&type=command`, {
|
|
54
138
|
method: 'POST',
|
|
55
139
|
headers: { 'Content-Type': 'application/json' },
|
|
56
|
-
body: JSON.stringify(
|
|
140
|
+
body: JSON.stringify(encryptedCmd), // Relay accepts string as raw body or JSON string
|
|
57
141
|
});
|
|
58
142
|
|
|
59
143
|
if (!pushRes.ok) {
|
|
@@ -73,10 +157,14 @@ async function callBlockWerk(name: string, input: Record<string, unknown>): Prom
|
|
|
73
157
|
throw new Error(`Relay poll failed: ${pollRes.status}`);
|
|
74
158
|
}
|
|
75
159
|
|
|
76
|
-
const data = (await pollRes.json()) as { pending?: boolean; result?:
|
|
160
|
+
const data = (await pollRes.json()) as { pending?: boolean; result?: string };
|
|
77
161
|
|
|
78
|
-
if (!data.pending) {
|
|
79
|
-
|
|
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);
|
|
80
168
|
}
|
|
81
169
|
|
|
82
170
|
// pending: true — relay timed out but result not yet ready, retry immediately
|
|
@@ -91,7 +179,7 @@ async function callBlockWerk(name: string, input: Record<string, unknown>): Prom
|
|
|
91
179
|
|
|
92
180
|
const server = new McpServer({
|
|
93
181
|
name: 'blockwerk',
|
|
94
|
-
version: '0.1
|
|
182
|
+
version: '0.2.1',
|
|
95
183
|
});
|
|
96
184
|
|
|
97
185
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
@@ -104,6 +192,10 @@ server.tool(
|
|
|
104
192
|
},
|
|
105
193
|
async ({ sessionId }) => {
|
|
106
194
|
SESSION_ID = sessionId;
|
|
195
|
+
posthog.capture({
|
|
196
|
+
distinctId: SESSION_ID,
|
|
197
|
+
event: 'mcp_bridge_configured',
|
|
198
|
+
});
|
|
107
199
|
return {
|
|
108
200
|
content: [
|
|
109
201
|
{
|
|
@@ -265,8 +357,129 @@ server.tool(
|
|
|
265
357
|
})
|
|
266
358
|
);
|
|
267
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
|
+
})
|
|
466
|
+
);
|
|
467
|
+
|
|
268
468
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
269
469
|
|
|
270
470
|
const transport = new StdioServerTransport();
|
|
271
471
|
await server.connect(transport);
|
|
272
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); });
|
package/tsconfig.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"outDir": "dist",
|
|
7
|
-
"rootDir": "src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true
|
|
11
|
-
},
|
|
12
|
-
"include": ["src"]
|
|
13
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src"]
|
|
13
|
+
}
|