@atropical/stroq-studio-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +157 -2
- package/dist/args.d.ts +6 -0
- package/dist/args.js +56 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +49 -0
- package/dist/mcp.d.ts +14 -0
- package/dist/mcp.js +24 -0
- package/dist/protocol.d.ts +111 -0
- package/dist/protocol.js +99 -0
- package/dist/relay.d.ts +85 -0
- package/dist/relay.js +325 -0
- package/package.json +31 -4
package/dist/relay.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// stroq-studio-mcp: the relay between an MCP client and a Strōq World Studio tab.
|
|
2
|
+
// Copyright (C) 2026 Atropical
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
4
|
+
/**
|
|
5
|
+
* The WebSocket side: one Studio tab at a time, on 127.0.0.1 only, from an
|
|
6
|
+
* allowed Origin only, after a mutual HMAC proof of the pairing token (or of
|
|
7
|
+
* the resume key both derived when they paired) bound to this port; then
|
|
8
|
+
* every frame is MACed with the session key and a sequence number (see
|
|
9
|
+
* protocol.ts). The token never crosses the wire and has ~130 bits, so a
|
|
10
|
+
* relay proof is no use for guessing it offline. Five wrong proofs replace it
|
|
11
|
+
* (at most once a minute). Token hellos after more than 30 failed or
|
|
12
|
+
* unfinished handshakes in a minute are answered late (growing, up to 5 s);
|
|
13
|
+
* successful pairings and resume hellos never count or wait, so nothing on
|
|
14
|
+
* the machine can block pairing.
|
|
15
|
+
*/
|
|
16
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { WebSocketServer } from 'ws';
|
|
18
|
+
import { MAX_FRAME, macText, PROTOCOL, parseTabMessage, proofText, tokenKey } from './protocol.js';
|
|
19
|
+
export const hmac = (key, text) => createHmac('sha256', key).update(text).digest('hex');
|
|
20
|
+
export const DEFAULT_PORT = 3021;
|
|
21
|
+
export const DEFAULT_ORIGINS = ['https://studio.stroq.club', 'http://localhost:3020'];
|
|
22
|
+
/** A handshake must finish within this time. */
|
|
23
|
+
export const HANDSHAKE_MS = 30_000;
|
|
24
|
+
/** Longest a tool call may take (wait_for_comments waits up to 10 min). */
|
|
25
|
+
export const CALL_TIMEOUT_MS = 11 * 60_000;
|
|
26
|
+
/** Wrong proofs before the token is replaced; replaced at most once per ROTATE_MS. */
|
|
27
|
+
export const MAX_WRONG = 5;
|
|
28
|
+
export const ROTATE_MS = 60_000;
|
|
29
|
+
/** Failed or unfinished handshakes a minute before token hellos are slowed down. */
|
|
30
|
+
export const FREE_STRIKES_PER_MIN = 30;
|
|
31
|
+
/** Delay per strike past the free ones, and its cap. */
|
|
32
|
+
export const STRIKE_DELAY_MS = 250;
|
|
33
|
+
export const MAX_DELAY_MS = 5_000;
|
|
34
|
+
/** The one string the person pastes into the Studio: port and token. */
|
|
35
|
+
export const pairingString = (port, token) => `${port}:${token}`;
|
|
36
|
+
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
37
|
+
/** A 26-character base32 token (130 random bits), grouped for pasting: XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX. */
|
|
38
|
+
export function newToken() {
|
|
39
|
+
const bytes = randomBytes(17);
|
|
40
|
+
let bits = 0;
|
|
41
|
+
let acc = 0;
|
|
42
|
+
let s = '';
|
|
43
|
+
for (const b of bytes) {
|
|
44
|
+
acc = (acc << 8) | b;
|
|
45
|
+
bits += 8;
|
|
46
|
+
while (bits >= 5 && s.length < 26) {
|
|
47
|
+
s += BASE32[(acc >>> (bits - 5)) & 31];
|
|
48
|
+
bits -= 5;
|
|
49
|
+
}
|
|
50
|
+
acc &= (1 << bits) - 1;
|
|
51
|
+
}
|
|
52
|
+
return s.match(/.{1,5}/g).join('-').replace(/-(\w)$/, '$1');
|
|
53
|
+
}
|
|
54
|
+
function sameSecret(a, b) {
|
|
55
|
+
const x = Buffer.from(a);
|
|
56
|
+
const y = Buffer.from(b);
|
|
57
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
58
|
+
}
|
|
59
|
+
export class Relay {
|
|
60
|
+
fixedPort;
|
|
61
|
+
/** The port it listens on (known once started). */
|
|
62
|
+
port;
|
|
63
|
+
origins;
|
|
64
|
+
wss = null;
|
|
65
|
+
token = newToken();
|
|
66
|
+
resume = null;
|
|
67
|
+
tab = null;
|
|
68
|
+
wrong = 0;
|
|
69
|
+
lastRotate = 0;
|
|
70
|
+
/** When failed or unfinished token handshakes ended (last minute). */
|
|
71
|
+
strikes = [];
|
|
72
|
+
toolList = [];
|
|
73
|
+
nextId = 1;
|
|
74
|
+
pending = new Map();
|
|
75
|
+
log;
|
|
76
|
+
onToolsChanged;
|
|
77
|
+
constructor(opts = {}) {
|
|
78
|
+
this.fixedPort = opts.port ?? null;
|
|
79
|
+
this.port = opts.port ?? DEFAULT_PORT;
|
|
80
|
+
this.origins = opts.origins ?? DEFAULT_ORIGINS;
|
|
81
|
+
this.log = opts.log ?? (() => undefined);
|
|
82
|
+
this.onToolsChanged = opts.onToolsChanged ?? (() => undefined);
|
|
83
|
+
}
|
|
84
|
+
get pairingToken() {
|
|
85
|
+
return this.token;
|
|
86
|
+
}
|
|
87
|
+
/** What the person pastes: "port:TOKEN". */
|
|
88
|
+
get pairing() {
|
|
89
|
+
return pairingString(this.port, this.token);
|
|
90
|
+
}
|
|
91
|
+
get tools() {
|
|
92
|
+
return this.tab ? this.toolList : [];
|
|
93
|
+
}
|
|
94
|
+
status() {
|
|
95
|
+
return this.tab ? { state: 'connected', origin: this.tab.origin, tools: this.toolList.length, port: this.port } : { state: 'waiting', pairing: this.pairing, token: this.token, port: this.port };
|
|
96
|
+
}
|
|
97
|
+
/** Starts listening on 127.0.0.1: the fixed port, else DEFAULT_PORT, else any free port (said on the log). */
|
|
98
|
+
async start() {
|
|
99
|
+
if (this.fixedPort !== null)
|
|
100
|
+
return this.listen(this.fixedPort);
|
|
101
|
+
try {
|
|
102
|
+
await this.listen(DEFAULT_PORT);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (e.code !== 'EADDRINUSE')
|
|
106
|
+
throw e;
|
|
107
|
+
await this.listen(0);
|
|
108
|
+
this.log(`Port ${DEFAULT_PORT} is taken, so the relay uses port ${this.port}.`);
|
|
109
|
+
this.announce();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
listen(port) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const wss = new WebSocketServer({ host: '127.0.0.1', port, maxPayload: MAX_FRAME, verifyClient: (info) => this.allowed(info.origin, info.req) });
|
|
115
|
+
wss.once('error', (e) => {
|
|
116
|
+
wss.close();
|
|
117
|
+
reject(e);
|
|
118
|
+
});
|
|
119
|
+
wss.once('listening', () => {
|
|
120
|
+
wss.removeAllListeners('error');
|
|
121
|
+
wss.on('error', (e) => this.log(`WebSocket error: ${e.message}`));
|
|
122
|
+
this.wss = wss;
|
|
123
|
+
this.port = wss.address().port;
|
|
124
|
+
if (port !== 0)
|
|
125
|
+
this.announce();
|
|
126
|
+
resolve();
|
|
127
|
+
});
|
|
128
|
+
wss.on('connection', (ws, req) => this.accept(ws, req.headers.origin ?? ''));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async stop() {
|
|
132
|
+
this.failAll('The relay stopped');
|
|
133
|
+
this.tab?.ws.close(1001, 'relay stopping');
|
|
134
|
+
this.tab = null;
|
|
135
|
+
const wss = this.wss;
|
|
136
|
+
this.wss = null;
|
|
137
|
+
if (wss) {
|
|
138
|
+
for (const c of wss.clients)
|
|
139
|
+
c.terminate();
|
|
140
|
+
await new Promise((r) => wss.close(() => r()));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Runs a tool in the tab. Never throws: errors come back as isError content. */
|
|
144
|
+
call(name, args) {
|
|
145
|
+
const tab = this.tab;
|
|
146
|
+
if (!tab)
|
|
147
|
+
return Promise.resolve(err(`No Studio tab is connected. In the Studio: Help → Connect an agent and paste ${this.pairing}.`));
|
|
148
|
+
if (!this.toolList.some((t) => t.name === name))
|
|
149
|
+
return Promise.resolve(err(`The Studio has no tool "${name}"`));
|
|
150
|
+
const id = this.nextId++;
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
this.pending.delete(id);
|
|
154
|
+
resolve(err(`"${name}" took longer than ${CALL_TIMEOUT_MS / 60_000} minutes`));
|
|
155
|
+
}, CALL_TIMEOUT_MS);
|
|
156
|
+
this.pending.set(id, { resolve, timer });
|
|
157
|
+
this.send(tab.ws, { type: 'call', id, name, arguments: args });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
allowed(origin, req) {
|
|
161
|
+
// Only from this machine (the bind already ensures it) and only from the Studio's origins.
|
|
162
|
+
const host = req.socket.remoteAddress ?? '';
|
|
163
|
+
if (host !== '127.0.0.1' && host !== '::ffff:127.0.0.1')
|
|
164
|
+
return false;
|
|
165
|
+
if (!this.origins.includes(origin)) {
|
|
166
|
+
this.log(`Refused a connection from origin "${origin || '(none)'}" (allowed: ${this.origins.join(', ')})`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
announce() {
|
|
172
|
+
this.log(`Strōq Studio relay on ws://127.0.0.1:${this.port}. In the Studio: Help → Connect an agent and paste ${this.pairing}`);
|
|
173
|
+
}
|
|
174
|
+
accept(ws, origin) {
|
|
175
|
+
let paired = false;
|
|
176
|
+
/** After hello: the nonces and the key this attempt uses. */
|
|
177
|
+
let attempt = null;
|
|
178
|
+
let session = '';
|
|
179
|
+
let seqIn = 0;
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
if (!paired)
|
|
182
|
+
ws.close(4000, 'not paired in time');
|
|
183
|
+
}, HANDSHAKE_MS);
|
|
184
|
+
const refuse = (why) => {
|
|
185
|
+
this.sendPlain(ws, { type: 'refused', reason: why });
|
|
186
|
+
ws.close(4003, 'refused');
|
|
187
|
+
};
|
|
188
|
+
ws.on('message', (data, isBinary) => {
|
|
189
|
+
if (isBinary)
|
|
190
|
+
return ws.close(4002, 'text frames only');
|
|
191
|
+
const msg = parseTabMessage(data.toString());
|
|
192
|
+
if (!msg)
|
|
193
|
+
return ws.close(4001, 'bad frame');
|
|
194
|
+
if (!paired) {
|
|
195
|
+
if (!attempt) {
|
|
196
|
+
if (msg.type !== 'hello')
|
|
197
|
+
return ws.close(4001, 'hello first');
|
|
198
|
+
if (msg.protocol !== PROTOCOL)
|
|
199
|
+
return refuse(`This relay speaks protocol ${PROTOCOL}, the Studio ${msg.protocol}: update @atropical/stroq-studio-mcp, or reload the Studio`);
|
|
200
|
+
if (this.tab)
|
|
201
|
+
return refuse('Another Studio tab is connected. Disconnect it first.');
|
|
202
|
+
if (msg.port !== this.port)
|
|
203
|
+
return refuse(`This relay listens on port ${this.port}, not ${msg.port}.`);
|
|
204
|
+
const key = msg.mode === 'resume' ? this.resume : tokenKey(this.token);
|
|
205
|
+
if (!key)
|
|
206
|
+
return refuse('Nothing to resume: connect with the pairing token.');
|
|
207
|
+
const relayNonce = randomBytes(32).toString('hex');
|
|
208
|
+
const a = { tabNonce: msg.nonce, relayNonce, key, mode: msg.mode };
|
|
209
|
+
attempt = a;
|
|
210
|
+
const challenge = () => this.sendPlain(ws, { type: 'challenge', nonce: relayNonce, proof: hmac(key, proofText('relay', this.port, a.tabNonce, relayNonce)) });
|
|
211
|
+
// Resume hellos (a 256-bit key) never wait; token hellos wait while failed handshakes pile up.
|
|
212
|
+
const wait = msg.mode === 'token' ? this.delay() : 0;
|
|
213
|
+
if (!wait)
|
|
214
|
+
return challenge();
|
|
215
|
+
this.sendPlain(ws, { type: 'wait', ms: wait });
|
|
216
|
+
setTimeout(challenge, wait);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (msg.type !== 'prove')
|
|
220
|
+
return ws.close(4001, 'prove next');
|
|
221
|
+
const p = attempt;
|
|
222
|
+
if (!sameSecret(msg.proof, hmac(p.key, proofText('tab', this.port, p.tabNonce, p.relayNonce))))
|
|
223
|
+
return refuse(p.mode === 'resume' ? 'The resume key no longer matches: connect with the pairing token.' : this.wrongProof());
|
|
224
|
+
if (this.tab)
|
|
225
|
+
return refuse('Another Studio tab is connected. Disconnect it first.');
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
paired = true;
|
|
228
|
+
this.wrong = 0;
|
|
229
|
+
if (p.mode === 'token')
|
|
230
|
+
this.token = newToken(); // one-time: used up once it pairs
|
|
231
|
+
// Bound to this proof: only the tab that proved it can derive them.
|
|
232
|
+
this.resume = hmac(p.key, proofText('resume', this.port, p.tabNonce, p.relayNonce));
|
|
233
|
+
session = hmac(p.key, proofText('session', this.port, p.tabNonce, p.relayNonce));
|
|
234
|
+
this.tab = { ws, origin, session, seqOut: 0 };
|
|
235
|
+
this.send(ws, { type: 'welcome', protocol: PROTOCOL });
|
|
236
|
+
this.log(`Studio tab connected (${origin})`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Paired: only sealed frames, in order, with a good MAC.
|
|
240
|
+
if (msg.type !== 'sealed' || msg.seq !== seqIn || !sameSecret(msg.mac, hmac(session, macText('tab', msg.seq, msg.body))))
|
|
241
|
+
return ws.close(4004, 'bad or replayed frame');
|
|
242
|
+
seqIn++;
|
|
243
|
+
const inner = parseTabMessage(msg.body);
|
|
244
|
+
if (!inner)
|
|
245
|
+
return ws.close(4001, 'bad frame');
|
|
246
|
+
switch (inner.type) {
|
|
247
|
+
case 'tools':
|
|
248
|
+
this.toolList = inner.tools;
|
|
249
|
+
this.onToolsChanged();
|
|
250
|
+
break;
|
|
251
|
+
case 'result': {
|
|
252
|
+
const r = this.pending.get(inner.id);
|
|
253
|
+
if (!r)
|
|
254
|
+
break;
|
|
255
|
+
clearTimeout(r.timer);
|
|
256
|
+
this.pending.delete(inner.id);
|
|
257
|
+
r.resolve({ content: inner.content, isError: !!inner.isError });
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'ping':
|
|
261
|
+
this.send(ws, { type: 'pong' });
|
|
262
|
+
break;
|
|
263
|
+
case 'pong':
|
|
264
|
+
break;
|
|
265
|
+
default:
|
|
266
|
+
return ws.close(4001, 'bad frame');
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
ws.on('close', () => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
// A token handshake that failed or was never finished counts; a successful one never does.
|
|
272
|
+
if (!paired && attempt?.mode === 'token')
|
|
273
|
+
this.strikes.push(Date.now());
|
|
274
|
+
if (!paired || this.tab?.ws !== ws)
|
|
275
|
+
return;
|
|
276
|
+
this.tab = null;
|
|
277
|
+
this.toolList = [];
|
|
278
|
+
this.failAll('The Studio tab disconnected');
|
|
279
|
+
this.onToolsChanged();
|
|
280
|
+
this.token = newToken();
|
|
281
|
+
this.log('Studio tab disconnected (reloading the same tab reconnects by itself).');
|
|
282
|
+
this.announce();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** How long a token hello waits: 0 up to FREE_STRIKES_PER_MIN strikes in the last minute, then growing to MAX_DELAY_MS. */
|
|
286
|
+
delay(now = Date.now()) {
|
|
287
|
+
this.strikes = this.strikes.filter((t) => now - t < 60_000);
|
|
288
|
+
const over = this.strikes.length - FREE_STRIKES_PER_MIN;
|
|
289
|
+
return over <= 0 ? 0 : Math.min(MAX_DELAY_MS, over * STRIKE_DELAY_MS);
|
|
290
|
+
}
|
|
291
|
+
/** A wrong token proof: counted; MAX_WRONG of them replace the token, at most once per ROTATE_MS. */
|
|
292
|
+
wrongProof(now = Date.now()) {
|
|
293
|
+
this.wrong++;
|
|
294
|
+
if (this.wrong >= MAX_WRONG && now - this.lastRotate >= ROTATE_MS) {
|
|
295
|
+
this.wrong = 0;
|
|
296
|
+
this.lastRotate = now;
|
|
297
|
+
this.token = newToken();
|
|
298
|
+
this.log(`${MAX_WRONG} wrong tokens: the pairing token changed.`);
|
|
299
|
+
this.announce();
|
|
300
|
+
}
|
|
301
|
+
return 'Wrong pairing token. Copy the one the relay printed.';
|
|
302
|
+
}
|
|
303
|
+
failAll(why) {
|
|
304
|
+
for (const [id, p] of this.pending) {
|
|
305
|
+
clearTimeout(p.timer);
|
|
306
|
+
p.resolve(err(why));
|
|
307
|
+
this.pending.delete(id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Before pairing: challenge and refused only. */
|
|
311
|
+
sendPlain(ws, m) {
|
|
312
|
+
if (ws.readyState === ws.OPEN)
|
|
313
|
+
ws.send(JSON.stringify(m));
|
|
314
|
+
}
|
|
315
|
+
/** To the paired tab: sealed with the session key and the next sequence number. */
|
|
316
|
+
send(ws, m) {
|
|
317
|
+
const tab = this.tab;
|
|
318
|
+
if (!tab || tab.ws !== ws || ws.readyState !== ws.OPEN)
|
|
319
|
+
return;
|
|
320
|
+
const body = JSON.stringify(m);
|
|
321
|
+
const seq = tab.seqOut++;
|
|
322
|
+
ws.send(JSON.stringify({ type: 'sealed', seq, body, mac: hmac(tab.session, macText('relay', seq, body)) }));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const err = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atropical/stroq-studio-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connects an MCP client (Claude Code, Claude Desktop) to an open Strōq World Studio tab, over a local WebSocket.",
|
|
5
|
+
"license": "GPL-3.0-only",
|
|
6
|
+
"author": "Atropical",
|
|
7
|
+
"homepage": "https://github.com/atropical/stroq-studio-mcp#readme",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/atropical/stroq-studio-mcp.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/atropical/stroq-studio-mcp/issues" },
|
|
10
|
+
"keywords": ["mcp", "model-context-protocol", "stroq", "world-studio", "claude"],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": { "stroq-studio-mcp": "dist/cli.js" },
|
|
13
|
+
"exports": { ".": "./dist/relay.js" },
|
|
14
|
+
"files": ["dist/*.js", "dist/*.d.ts", "README.md", "LICENSE"],
|
|
15
|
+
"engines": { "node": ">=20" },
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
19
|
+
"type-check": "tsc --noEmit",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"prepublishOnly": "npm run build && npm run type-check"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
25
|
+
"ws": "^8.22.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/bun": "latest",
|
|
29
|
+
"@types/node": "^22",
|
|
30
|
+
"@types/ws": "^8.18.1",
|
|
31
|
+
"typescript": "^5.9.2"
|
|
32
|
+
}
|
|
33
|
+
}
|