@atropical/stroq-studio-mcp 0.0.0-stage → 0.1.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/LICENSE +674 -0
- package/README.md +197 -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 +45 -0
- package/dist/mcp.js +46 -0
- package/dist/modelFile.d.ts +33 -0
- package/dist/modelFile.js +255 -0
- package/dist/protocol.d.ts +120 -0
- package/dist/protocol.js +108 -0
- package/dist/relay.d.ts +96 -0
- package/dist/relay.js +361 -0
- package/package.json +57 -4
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
* import_model_file's local side: the one place the relay reads files. Only a
|
|
6
|
+
* .glb, a .gltf (with the .bin buffers and PNG/JPEG/WebP/KTX2 images it
|
|
7
|
+
* names, from its folder and at most two levels down) or a .zip of the same;
|
|
8
|
+
* no symlinks, no hidden files, nothing outside the model's folder; buffer
|
|
9
|
+
* sizes must match the glTF and images their type; at most MAX_MODEL_BYTES.
|
|
10
|
+
*/
|
|
11
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
12
|
+
import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path';
|
|
13
|
+
import { inflateRawSync } from 'node:zlib';
|
|
14
|
+
export const MAX_MODEL_BYTES = 100 * 1024 * 1024;
|
|
15
|
+
/** Bytes per chunk sent to the tab (base64 makes a frame about 1.4 MB). */
|
|
16
|
+
export const CHUNK_BYTES = 1024 * 1024;
|
|
17
|
+
export const MODEL_EXTENSIONS = ['.glb', '.gltf', '.zip'];
|
|
18
|
+
/** What a .gltf (or a zip) may carry beside the model. */
|
|
19
|
+
export const SIBLING_EXTENSIONS = ['.bin', '.png', '.jpg', '.jpeg', '.webp', '.ktx2'];
|
|
20
|
+
/** Folders below the model's own that a sibling may sit in. */
|
|
21
|
+
export const MAX_DEPTH = 2;
|
|
22
|
+
const MAX_SIBLINGS = 200;
|
|
23
|
+
export class ModelFileError extends Error {
|
|
24
|
+
}
|
|
25
|
+
const mb = (n) => `${Math.round(n / 1048576)} MB`;
|
|
26
|
+
/** A regular file, not a symlink, whose real path is inside root (a real path). Returns its size. */
|
|
27
|
+
async function plainFileInside(path, root) {
|
|
28
|
+
let st;
|
|
29
|
+
try {
|
|
30
|
+
st = await lstat(path);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new ModelFileError(`No file at ${path}`);
|
|
34
|
+
}
|
|
35
|
+
if (st.isSymbolicLink())
|
|
36
|
+
throw new ModelFileError(`${path} is a symlink; give the real file`);
|
|
37
|
+
if (!st.isFile())
|
|
38
|
+
throw new ModelFileError(`${path} is not a file`);
|
|
39
|
+
const rel = relative(root, await realpath(path));
|
|
40
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
|
|
41
|
+
throw new ModelFileError(`${path} is outside the model's folder`);
|
|
42
|
+
return st.size;
|
|
43
|
+
}
|
|
44
|
+
/** A relative path's checks: no scheme, absolute, '..' or hidden segment; an allowed type; at most MAX_DEPTH folders down. */
|
|
45
|
+
function checkRelative(p, allowed, what) {
|
|
46
|
+
const parts = p.replace(/\\/g, '/').split('/');
|
|
47
|
+
if (!p || /^[a-z][a-z0-9+.-]*:/i.test(p) || /^[\\/]/.test(p) || parts.some((s) => s === '' || s === '..'))
|
|
48
|
+
throw new ModelFileError(`${what} names a file outside its folder: ${p}`);
|
|
49
|
+
if (parts.some((s) => s.startsWith('.')))
|
|
50
|
+
throw new ModelFileError(`${what} names a hidden file: ${p}`);
|
|
51
|
+
if (parts.length - 1 > MAX_DEPTH)
|
|
52
|
+
throw new ModelFileError(`${what} names a file more than ${MAX_DEPTH} folders down: ${p}`);
|
|
53
|
+
if (!allowed.includes(extname(p).toLowerCase()))
|
|
54
|
+
throw new ModelFileError(`${what} names ${p}: only ${allowed.join(', ')} files may come with a model`);
|
|
55
|
+
return parts.join('/');
|
|
56
|
+
}
|
|
57
|
+
const IMAGE_EXTENSIONS = SIBLING_EXTENSIONS.filter((e) => e !== '.bin');
|
|
58
|
+
/** The files a .gltf names (buffers and images), checked; data: URIs are inside the file already. */
|
|
59
|
+
export function gltfSiblings(json) {
|
|
60
|
+
const out = new Map();
|
|
61
|
+
const doc = (json ?? {});
|
|
62
|
+
const add = (item, buffer) => {
|
|
63
|
+
const uri = item?.uri;
|
|
64
|
+
if (typeof uri !== 'string' || uri.startsWith('data:'))
|
|
65
|
+
return;
|
|
66
|
+
let p;
|
|
67
|
+
try {
|
|
68
|
+
p = decodeURIComponent(uri);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new ModelFileError(`Bad file name in the glTF: ${uri}`);
|
|
72
|
+
}
|
|
73
|
+
const path = checkRelative(p, buffer ? ['.bin'] : IMAGE_EXTENSIONS, 'The glTF');
|
|
74
|
+
const prev = out.get(path) ?? { path };
|
|
75
|
+
if (buffer) {
|
|
76
|
+
const len = item?.byteLength;
|
|
77
|
+
if (typeof len !== 'number' || !Number.isInteger(len) || len < 0)
|
|
78
|
+
throw new ModelFileError(`The glTF buffer ${uri} has no byteLength`);
|
|
79
|
+
if (prev.byteLength !== undefined && prev.byteLength !== len)
|
|
80
|
+
throw new ModelFileError(`The glTF names ${uri} with two sizes`);
|
|
81
|
+
prev.byteLength = len;
|
|
82
|
+
}
|
|
83
|
+
else if (prev.byteLength !== undefined)
|
|
84
|
+
throw new ModelFileError(`The glTF names ${uri} as a buffer and an image`);
|
|
85
|
+
out.set(path, prev);
|
|
86
|
+
};
|
|
87
|
+
for (const b of Array.isArray(doc.buffers) ? doc.buffers : [])
|
|
88
|
+
add(b, true);
|
|
89
|
+
for (const i of Array.isArray(doc.images) ? doc.images : [])
|
|
90
|
+
add(i, false);
|
|
91
|
+
if (out.size > MAX_SIBLINGS)
|
|
92
|
+
throw new ModelFileError(`The glTF names more than ${MAX_SIBLINGS} files`);
|
|
93
|
+
return [...out.values()];
|
|
94
|
+
}
|
|
95
|
+
const MAGIC = {
|
|
96
|
+
'.png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
97
|
+
'.jpg': [0xff, 0xd8, 0xff],
|
|
98
|
+
'.jpeg': [0xff, 0xd8, 0xff],
|
|
99
|
+
'.ktx2': [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
100
|
+
};
|
|
101
|
+
const GLB = [0x67, 0x6c, 0x54, 0x46]; // "glTF"
|
|
102
|
+
const starts = (d, sig, at = 0) => d.length >= at + sig.length && sig.every((b, i) => d[at + i] === b);
|
|
103
|
+
/** An image's first bytes must match its type (a .env renamed .png is refused). */
|
|
104
|
+
export function checkImage(name, d) {
|
|
105
|
+
const ext = extname(name).toLowerCase();
|
|
106
|
+
const sig = MAGIC[ext];
|
|
107
|
+
const ok = ext === '.webp' ? starts(d, [0x52, 0x49, 0x46, 0x46]) && starts(d, [0x57, 0x45, 0x42, 0x50], 8) : !!sig && starts(d, sig);
|
|
108
|
+
if (!ok)
|
|
109
|
+
throw new ModelFileError(`${name} is not a ${ext.slice(1).toUpperCase()} image`);
|
|
110
|
+
}
|
|
111
|
+
function checkSibling(s, d) {
|
|
112
|
+
if (s.byteLength === undefined)
|
|
113
|
+
return checkImage(s.path, d);
|
|
114
|
+
if (d.length !== s.byteLength)
|
|
115
|
+
throw new ModelFileError(`${s.path} is ${d.length} bytes, the glTF says ${s.byteLength}`);
|
|
116
|
+
}
|
|
117
|
+
function parseGltf(d, name) {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(new TextDecoder().decode(d));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new ModelFileError(`${name} is not valid JSON`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A zip's entries, read and checked without trusting it: only .glb/.gltf and
|
|
127
|
+
* the sibling types, no hidden segments, at most MAX_DEPTH folders down,
|
|
128
|
+
* stored or deflated, no encryption or zip64, within max. A .gltf inside is
|
|
129
|
+
* checked like one on disk (its siblings must be in the zip); images by type.
|
|
130
|
+
*/
|
|
131
|
+
export function checkZip(zip, max = MAX_MODEL_BYTES) {
|
|
132
|
+
const dv = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
|
133
|
+
let eocd = -1;
|
|
134
|
+
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65535); i--)
|
|
135
|
+
if (dv.getUint32(i, true) === 0x06054b50) {
|
|
136
|
+
eocd = i;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (eocd < 0)
|
|
140
|
+
throw new ModelFileError('The .zip is not a zip file');
|
|
141
|
+
const count = dv.getUint16(eocd + 10, true);
|
|
142
|
+
let at = dv.getUint32(eocd + 16, true);
|
|
143
|
+
if (count === 0xffff || at === 0xffffffff)
|
|
144
|
+
throw new ModelFileError('Zip64 files are not supported');
|
|
145
|
+
if (count > MAX_SIBLINGS + 10)
|
|
146
|
+
throw new ModelFileError(`The .zip holds more than ${MAX_SIBLINGS + 10} files`);
|
|
147
|
+
const entries = new Map();
|
|
148
|
+
let total = 0;
|
|
149
|
+
let models = 0;
|
|
150
|
+
const dec = new TextDecoder();
|
|
151
|
+
for (let n = 0; n < count; n++) {
|
|
152
|
+
if (at + 46 > zip.length || dv.getUint32(at, true) !== 0x02014b50)
|
|
153
|
+
throw new ModelFileError('The .zip is damaged');
|
|
154
|
+
const flags = dv.getUint16(at + 8, true);
|
|
155
|
+
const method = dv.getUint16(at + 10, true);
|
|
156
|
+
const csize = dv.getUint32(at + 20, true);
|
|
157
|
+
const usize = dv.getUint32(at + 24, true);
|
|
158
|
+
const nlen = dv.getUint16(at + 28, true);
|
|
159
|
+
const xlen = dv.getUint16(at + 30, true);
|
|
160
|
+
const clen = dv.getUint16(at + 32, true);
|
|
161
|
+
const local = dv.getUint32(at + 42, true);
|
|
162
|
+
const name = dec.decode(zip.subarray(at + 46, at + 46 + nlen));
|
|
163
|
+
at += 46 + nlen + xlen + clen;
|
|
164
|
+
if (name.endsWith('/')) {
|
|
165
|
+
checkRelative(`${name}x.bin`, ['.bin'], 'The .zip'); // folders: same path rules
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const path = checkRelative(name, ['.glb', '.gltf', ...SIBLING_EXTENSIONS], 'The .zip');
|
|
169
|
+
if (flags & 1)
|
|
170
|
+
throw new ModelFileError('Encrypted zips are not supported');
|
|
171
|
+
if (method !== 0 && method !== 8)
|
|
172
|
+
throw new ModelFileError(`${name}: only stored or deflated zip entries`);
|
|
173
|
+
total += usize;
|
|
174
|
+
if (total > max)
|
|
175
|
+
throw new ModelFileError(`The model and its files are over ${mb(max)}`);
|
|
176
|
+
if (local + 30 > zip.length || dv.getUint32(local, true) !== 0x04034b50)
|
|
177
|
+
throw new ModelFileError('The .zip is damaged');
|
|
178
|
+
const start = local + 30 + dv.getUint16(local + 26, true) + dv.getUint16(local + 28, true);
|
|
179
|
+
const raw = zip.subarray(start, start + csize);
|
|
180
|
+
let data;
|
|
181
|
+
try {
|
|
182
|
+
data = method === 0 ? raw : new Uint8Array(inflateRawSync(raw, { maxOutputLength: Math.max(1, usize) }));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
throw new ModelFileError(`${name} in the .zip is damaged`);
|
|
186
|
+
}
|
|
187
|
+
if (data.length !== usize)
|
|
188
|
+
throw new ModelFileError(`${name} in the .zip is damaged`);
|
|
189
|
+
const ext = extname(path).toLowerCase();
|
|
190
|
+
if (ext === '.glb' || ext === '.gltf')
|
|
191
|
+
models++;
|
|
192
|
+
if (ext === '.glb' && !starts(data, GLB))
|
|
193
|
+
throw new ModelFileError(`${name} is not a GLB`);
|
|
194
|
+
if (IMAGE_EXTENSIONS.includes(ext))
|
|
195
|
+
checkImage(path, data);
|
|
196
|
+
entries.set(path, data);
|
|
197
|
+
}
|
|
198
|
+
if (models === 0)
|
|
199
|
+
throw new ModelFileError('The .zip holds no .glb or .gltf');
|
|
200
|
+
for (const [path, data] of entries) {
|
|
201
|
+
if (extname(path).toLowerCase() !== '.gltf')
|
|
202
|
+
continue;
|
|
203
|
+
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : '';
|
|
204
|
+
for (const s of gltfSiblings(parseGltf(data, path))) {
|
|
205
|
+
const d = entries.get(dir + s.path);
|
|
206
|
+
if (!d)
|
|
207
|
+
throw new ModelFileError(`${path} names ${s.path}, which is not in the .zip`);
|
|
208
|
+
checkSibling(s, d);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Reads a model for import, checked as above. Throws ModelFileError with a message for the agent. */
|
|
213
|
+
export async function readModelFiles(path, max = MAX_MODEL_BYTES) {
|
|
214
|
+
if (typeof path !== 'string' || !isAbsolute(path))
|
|
215
|
+
throw new ModelFileError('Give the model as an absolute path');
|
|
216
|
+
const ext = extname(path).toLowerCase();
|
|
217
|
+
if (!MODEL_EXTENSIONS.includes(ext))
|
|
218
|
+
throw new ModelFileError(`Only ${MODEL_EXTENSIONS.join(', ')} files (export a GLB from Blender)`);
|
|
219
|
+
if (basename(path).startsWith('.'))
|
|
220
|
+
throw new ModelFileError('Hidden files are not models');
|
|
221
|
+
let root;
|
|
222
|
+
try {
|
|
223
|
+
root = await realpath(dirname(path));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
throw new ModelFileError(`No folder at ${dirname(path)}`);
|
|
227
|
+
}
|
|
228
|
+
const main = join(root, basename(path));
|
|
229
|
+
let total = await plainFileInside(main, root);
|
|
230
|
+
if (total > max)
|
|
231
|
+
throw new ModelFileError(`The file is over ${mb(max)}`);
|
|
232
|
+
const mainData = new Uint8Array(await readFile(main));
|
|
233
|
+
if (mainData.length > max)
|
|
234
|
+
throw new ModelFileError(`The file is over ${mb(max)}`);
|
|
235
|
+
const files = [{ name: basename(main), data: mainData }];
|
|
236
|
+
if (ext === '.glb') {
|
|
237
|
+
if (!starts(mainData, GLB))
|
|
238
|
+
throw new ModelFileError(`${basename(main)} is not a GLB`);
|
|
239
|
+
return files;
|
|
240
|
+
}
|
|
241
|
+
if (ext === '.zip') {
|
|
242
|
+
checkZip(mainData, max);
|
|
243
|
+
return files;
|
|
244
|
+
}
|
|
245
|
+
for (const s of gltfSiblings(parseGltf(mainData, basename(main)))) {
|
|
246
|
+
const p = join(root, ...s.path.split('/'));
|
|
247
|
+
total += await plainFileInside(p, root);
|
|
248
|
+
if (total > max)
|
|
249
|
+
throw new ModelFileError(`The model and its files are over ${mb(max)}`);
|
|
250
|
+
const data = new Uint8Array(await readFile(p));
|
|
251
|
+
checkSibling(s, data);
|
|
252
|
+
files.push({ name: s.path, data });
|
|
253
|
+
}
|
|
254
|
+
return files;
|
|
255
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire protocol between the relay and the Studio tab: JSON text frames
|
|
3
|
+
* over one WebSocket.
|
|
4
|
+
*
|
|
5
|
+
* Pairing is mutual and the secret never crosses the wire. The key is the
|
|
6
|
+
* pairing token (26 base32 characters, ~130 bits; dashes and case ignored)
|
|
7
|
+
* or, to reconnect after a reload, the resume key both sides derived when
|
|
8
|
+
* they last paired. Every proof names the port the tab dialled, so a relay in
|
|
9
|
+
* the middle on another port cannot pass them on.
|
|
10
|
+
*
|
|
11
|
+
* tab → relay hello { type, protocol, mode, port, nonce } mode 'token' | 'resume'; nonce: 32 random bytes, hex
|
|
12
|
+
* relay → tab challenge { type, nonce, proof } proof = HMAC-SHA256(key, "relay|" + port + "|" + tabNonce + "|" + relayNonce), hex
|
|
13
|
+
* tab → relay prove { type, proof } after checking the relay's proof: HMAC(key, "tab|" + port + "|" + …)
|
|
14
|
+
* relay → tab wait { type, ms } the challenge comes late (many failed handshakes lately)
|
|
15
|
+
* relay → tab refused { type, reason } then the relay closes the socket
|
|
16
|
+
*
|
|
17
|
+
* Then both hold session = HMAC(key, "session|…") and resume = HMAC(key,
|
|
18
|
+
* "resume|…"), never sent. Every later frame, both ways, is sealed:
|
|
19
|
+
*
|
|
20
|
+
* { type: "sealed", seq, body, mac } body: the frame's JSON; seq counts from 0 per direction;
|
|
21
|
+
* mac = HMAC(session, dir + "|" + seq + "|" + body), dir "relay" or "tab"
|
|
22
|
+
*
|
|
23
|
+
* A bad MAC or a sequence number out of order closes the socket. Sealed bodies:
|
|
24
|
+
*
|
|
25
|
+
* relay → tab welcome { type, protocol } first sealed frame: paired
|
|
26
|
+
* tab → relay tools { type, tools: ToolInfo[] } any time; the relay tells the MCP client the list changed
|
|
27
|
+
* relay → tab call { type, id, name, arguments }
|
|
28
|
+
* tab → relay result { type, id, content, isError? } content is MCP content (text and image blocks)
|
|
29
|
+
* either ping / pong { type } keep-alive
|
|
30
|
+
*/
|
|
31
|
+
export declare const PROTOCOL = 3;
|
|
32
|
+
/** The pairing token as a key: upper case, letters and digits only. */
|
|
33
|
+
export declare const tokenKey: (t: string) => string;
|
|
34
|
+
export type ProofKind = 'relay' | 'tab' | 'resume' | 'session';
|
|
35
|
+
export declare const proofText: (who: ProofKind, port: number, tabNonce: string, relayNonce: string) => string;
|
|
36
|
+
export declare const macText: (dir: "relay" | "tab", seq: number, body: string) => string;
|
|
37
|
+
export interface ToolInfo {
|
|
38
|
+
name: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: 'object';
|
|
42
|
+
[k: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export type Content = {
|
|
46
|
+
type: 'text';
|
|
47
|
+
text: string;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'image';
|
|
50
|
+
data: string;
|
|
51
|
+
mimeType: string;
|
|
52
|
+
};
|
|
53
|
+
export type TabMessage = {
|
|
54
|
+
type: 'hello';
|
|
55
|
+
protocol: number;
|
|
56
|
+
mode: 'token' | 'resume';
|
|
57
|
+
port: number;
|
|
58
|
+
nonce: string;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'sealed';
|
|
61
|
+
seq: number;
|
|
62
|
+
body: string;
|
|
63
|
+
mac: string;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'prove';
|
|
66
|
+
proof: string;
|
|
67
|
+
} | {
|
|
68
|
+
type: 'tools';
|
|
69
|
+
tools: ToolInfo[];
|
|
70
|
+
} | {
|
|
71
|
+
type: 'result';
|
|
72
|
+
id: number;
|
|
73
|
+
content: Content[];
|
|
74
|
+
isError?: boolean;
|
|
75
|
+
} | {
|
|
76
|
+
type: 'ping';
|
|
77
|
+
} | {
|
|
78
|
+
type: 'pong';
|
|
79
|
+
};
|
|
80
|
+
export type RelayMessage = {
|
|
81
|
+
type: 'challenge';
|
|
82
|
+
nonce: string;
|
|
83
|
+
proof: string;
|
|
84
|
+
} | {
|
|
85
|
+
type: 'welcome';
|
|
86
|
+
protocol: number;
|
|
87
|
+
} | {
|
|
88
|
+
type: 'sealed';
|
|
89
|
+
seq: number;
|
|
90
|
+
body: string;
|
|
91
|
+
mac: string;
|
|
92
|
+
} | {
|
|
93
|
+
type: 'wait';
|
|
94
|
+
ms: number;
|
|
95
|
+
} | {
|
|
96
|
+
type: 'refused';
|
|
97
|
+
reason: string;
|
|
98
|
+
} | {
|
|
99
|
+
type: 'call';
|
|
100
|
+
id: number;
|
|
101
|
+
name: string;
|
|
102
|
+
arguments: Record<string, unknown>;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'ping';
|
|
105
|
+
} | {
|
|
106
|
+
type: 'pong';
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* import_model_file runs in the relay: it reads the file and sends it as
|
|
110
|
+
* import_model_part calls { upload, file, size, offset, data (base64) }, then
|
|
111
|
+
* calls the tab's import_model_file { upload, files: [{ name, size }], name?,
|
|
112
|
+
* category?, tags? }. The relay lists it only when the tab lists both, and
|
|
113
|
+
* never lists import_model_part.
|
|
114
|
+
*/
|
|
115
|
+
export declare const IMPORT_FILE = "import_model_file";
|
|
116
|
+
export declare const IMPORT_PART = "import_model_part";
|
|
117
|
+
/** Largest frame the relay accepts from the tab (screenshots are big). */
|
|
118
|
+
export declare const MAX_FRAME: number;
|
|
119
|
+
/** A frame from the tab, checked; null when it is not one. */
|
|
120
|
+
export declare function parseTabMessage(raw: string): TabMessage | null;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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 wire protocol between the relay and the Studio tab: JSON text frames
|
|
6
|
+
* over one WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* Pairing is mutual and the secret never crosses the wire. The key is the
|
|
9
|
+
* pairing token (26 base32 characters, ~130 bits; dashes and case ignored)
|
|
10
|
+
* or, to reconnect after a reload, the resume key both sides derived when
|
|
11
|
+
* they last paired. Every proof names the port the tab dialled, so a relay in
|
|
12
|
+
* the middle on another port cannot pass them on.
|
|
13
|
+
*
|
|
14
|
+
* tab → relay hello { type, protocol, mode, port, nonce } mode 'token' | 'resume'; nonce: 32 random bytes, hex
|
|
15
|
+
* relay → tab challenge { type, nonce, proof } proof = HMAC-SHA256(key, "relay|" + port + "|" + tabNonce + "|" + relayNonce), hex
|
|
16
|
+
* tab → relay prove { type, proof } after checking the relay's proof: HMAC(key, "tab|" + port + "|" + …)
|
|
17
|
+
* relay → tab wait { type, ms } the challenge comes late (many failed handshakes lately)
|
|
18
|
+
* relay → tab refused { type, reason } then the relay closes the socket
|
|
19
|
+
*
|
|
20
|
+
* Then both hold session = HMAC(key, "session|…") and resume = HMAC(key,
|
|
21
|
+
* "resume|…"), never sent. Every later frame, both ways, is sealed:
|
|
22
|
+
*
|
|
23
|
+
* { type: "sealed", seq, body, mac } body: the frame's JSON; seq counts from 0 per direction;
|
|
24
|
+
* mac = HMAC(session, dir + "|" + seq + "|" + body), dir "relay" or "tab"
|
|
25
|
+
*
|
|
26
|
+
* A bad MAC or a sequence number out of order closes the socket. Sealed bodies:
|
|
27
|
+
*
|
|
28
|
+
* relay → tab welcome { type, protocol } first sealed frame: paired
|
|
29
|
+
* tab → relay tools { type, tools: ToolInfo[] } any time; the relay tells the MCP client the list changed
|
|
30
|
+
* relay → tab call { type, id, name, arguments }
|
|
31
|
+
* tab → relay result { type, id, content, isError? } content is MCP content (text and image blocks)
|
|
32
|
+
* either ping / pong { type } keep-alive
|
|
33
|
+
*/
|
|
34
|
+
export const PROTOCOL = 3;
|
|
35
|
+
/** The pairing token as a key: upper case, letters and digits only. */
|
|
36
|
+
export const tokenKey = (t) => t.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
37
|
+
export const proofText = (who, port, tabNonce, relayNonce) => `${who}|${port}|${tabNonce}|${relayNonce}`;
|
|
38
|
+
export const macText = (dir, seq, body) => `${dir}|${seq}|${body}`;
|
|
39
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
40
|
+
/**
|
|
41
|
+
* import_model_file runs in the relay: it reads the file and sends it as
|
|
42
|
+
* import_model_part calls { upload, file, size, offset, data (base64) }, then
|
|
43
|
+
* calls the tab's import_model_file { upload, files: [{ name, size }], name?,
|
|
44
|
+
* category?, tags? }. The relay lists it only when the tab lists both, and
|
|
45
|
+
* never lists import_model_part.
|
|
46
|
+
*/
|
|
47
|
+
export const IMPORT_FILE = 'import_model_file';
|
|
48
|
+
export const IMPORT_PART = 'import_model_part';
|
|
49
|
+
/** Largest frame the relay accepts from the tab (screenshots are big). */
|
|
50
|
+
export const MAX_FRAME = 32 * 1024 * 1024;
|
|
51
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
52
|
+
function isContent(v) {
|
|
53
|
+
if (!isObj(v))
|
|
54
|
+
return false;
|
|
55
|
+
if (v.type === 'text')
|
|
56
|
+
return typeof v.text === 'string';
|
|
57
|
+
if (v.type === 'image')
|
|
58
|
+
return typeof v.data === 'string' && typeof v.mimeType === 'string' && /^image\/(png|jpeg|webp|gif)$/.test(v.mimeType);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
function isTool(v) {
|
|
62
|
+
return (isObj(v) &&
|
|
63
|
+
typeof v.name === 'string' &&
|
|
64
|
+
/^[A-Za-z0-9_-]{1,64}$/.test(v.name) &&
|
|
65
|
+
(v.description === undefined || typeof v.description === 'string') &&
|
|
66
|
+
isObj(v.inputSchema) &&
|
|
67
|
+
v.inputSchema.type === 'object');
|
|
68
|
+
}
|
|
69
|
+
/** A frame from the tab, checked; null when it is not one. */
|
|
70
|
+
export function parseTabMessage(raw) {
|
|
71
|
+
let v;
|
|
72
|
+
try {
|
|
73
|
+
v = JSON.parse(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!isObj(v))
|
|
79
|
+
return null;
|
|
80
|
+
switch (v.type) {
|
|
81
|
+
case 'hello':
|
|
82
|
+
if (typeof v.protocol !== 'number')
|
|
83
|
+
return null;
|
|
84
|
+
// Another protocol version gets a clear refusal, whatever else it sent.
|
|
85
|
+
if (v.protocol !== PROTOCOL)
|
|
86
|
+
return { type: 'hello', protocol: v.protocol, mode: 'token', port: 0, nonce: '' };
|
|
87
|
+
return (v.mode === 'token' || v.mode === 'resume') && Number.isInteger(v.port) && typeof v.nonce === 'string' && HEX64.test(v.nonce)
|
|
88
|
+
? { type: 'hello', protocol: v.protocol, mode: v.mode, port: v.port, nonce: v.nonce }
|
|
89
|
+
: null;
|
|
90
|
+
case 'sealed':
|
|
91
|
+
return Number.isInteger(v.seq) && v.seq >= 0 && typeof v.body === 'string' && typeof v.mac === 'string' && HEX64.test(v.mac)
|
|
92
|
+
? { type: 'sealed', seq: v.seq, body: v.body, mac: v.mac }
|
|
93
|
+
: null;
|
|
94
|
+
case 'prove':
|
|
95
|
+
return typeof v.proof === 'string' && HEX64.test(v.proof) ? { type: 'prove', proof: v.proof } : null;
|
|
96
|
+
case 'tools':
|
|
97
|
+
return Array.isArray(v.tools) && v.tools.length <= 500 && v.tools.every(isTool) ? { type: 'tools', tools: v.tools } : null;
|
|
98
|
+
case 'result':
|
|
99
|
+
return Number.isInteger(v.id) && Array.isArray(v.content) && v.content.every(isContent)
|
|
100
|
+
? { type: 'result', id: v.id, content: v.content, isError: v.isError === true }
|
|
101
|
+
: null;
|
|
102
|
+
case 'ping':
|
|
103
|
+
case 'pong':
|
|
104
|
+
return { type: v.type };
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type Content, type ToolInfo } from './protocol.js';
|
|
2
|
+
export declare const hmac: (key: string, text: string) => string;
|
|
3
|
+
export declare const DEFAULT_PORT = 3021;
|
|
4
|
+
export declare const DEFAULT_ORIGINS: string[];
|
|
5
|
+
/** A handshake must finish within this time. */
|
|
6
|
+
export declare const HANDSHAKE_MS = 30000;
|
|
7
|
+
/** Longest a tool call may take (wait_for_comments waits up to 10 min). */
|
|
8
|
+
export declare const CALL_TIMEOUT_MS: number;
|
|
9
|
+
/** Wrong proofs before the token is replaced; replaced at most once per ROTATE_MS. */
|
|
10
|
+
export declare const MAX_WRONG = 5;
|
|
11
|
+
export declare const ROTATE_MS = 60000;
|
|
12
|
+
/** Failed or unfinished handshakes a minute before token hellos are slowed down. */
|
|
13
|
+
export declare const FREE_STRIKES_PER_MIN = 30;
|
|
14
|
+
/** Delay per strike past the free ones, and its cap. */
|
|
15
|
+
export declare const STRIKE_DELAY_MS = 250;
|
|
16
|
+
export declare const MAX_DELAY_MS = 5000;
|
|
17
|
+
/** The one string the person pastes into the Studio: port and token. */
|
|
18
|
+
export declare const pairingString: (port: number, token: string) => string;
|
|
19
|
+
/** A 26-character base32 token (130 random bits), grouped for pasting: XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX. */
|
|
20
|
+
export declare function newToken(): string;
|
|
21
|
+
export type RelayStatus = {
|
|
22
|
+
state: 'waiting';
|
|
23
|
+
pairing: string;
|
|
24
|
+
token: string;
|
|
25
|
+
port: number;
|
|
26
|
+
} | {
|
|
27
|
+
state: 'connected';
|
|
28
|
+
origin: string;
|
|
29
|
+
tools: number;
|
|
30
|
+
port: number;
|
|
31
|
+
};
|
|
32
|
+
export interface RelayOptions {
|
|
33
|
+
/** A fixed port (fails when busy). Without it: DEFAULT_PORT, else any free port. */
|
|
34
|
+
port?: number;
|
|
35
|
+
origins?: string[];
|
|
36
|
+
/** Where messages for the person go (stderr in the CLI; stdout belongs to MCP). */
|
|
37
|
+
log?: (line: string) => void;
|
|
38
|
+
onToolsChanged?: () => void;
|
|
39
|
+
}
|
|
40
|
+
export declare class Relay {
|
|
41
|
+
private fixedPort;
|
|
42
|
+
/** The port it listens on (known once started). */
|
|
43
|
+
port: number;
|
|
44
|
+
readonly origins: string[];
|
|
45
|
+
private wss;
|
|
46
|
+
private token;
|
|
47
|
+
private resume;
|
|
48
|
+
private tab;
|
|
49
|
+
private wrong;
|
|
50
|
+
private lastRotate;
|
|
51
|
+
/** When failed or unfinished token handshakes ended (last minute). */
|
|
52
|
+
private strikes;
|
|
53
|
+
private toolList;
|
|
54
|
+
private nextId;
|
|
55
|
+
private pending;
|
|
56
|
+
private log;
|
|
57
|
+
private onToolsChanged;
|
|
58
|
+
constructor(opts?: RelayOptions);
|
|
59
|
+
get pairingToken(): string;
|
|
60
|
+
/** What the person pastes: "port:TOKEN". */
|
|
61
|
+
get pairing(): string;
|
|
62
|
+
get tools(): ToolInfo[];
|
|
63
|
+
status(): RelayStatus;
|
|
64
|
+
/** Starts listening on 127.0.0.1: the fixed port, else DEFAULT_PORT, else any free port (said on the log). */
|
|
65
|
+
start(): Promise<void>;
|
|
66
|
+
private listen;
|
|
67
|
+
stop(): Promise<void>;
|
|
68
|
+
/** Runs a tool in the tab. Never throws: errors come back as isError content. */
|
|
69
|
+
call(name: string, args: Record<string, unknown>): Promise<{
|
|
70
|
+
content: Content[];
|
|
71
|
+
isError: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
/** Whether the tab takes model files (it lists both import tools). */
|
|
74
|
+
get canImport(): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* import_model_file: reads the model here (modelFile.ts checks), sends it to
|
|
77
|
+
* the tab in CHUNK_BYTES parts (one import_model_part call each, in order,
|
|
78
|
+
* each answered before the next), then asks the tab to import it.
|
|
79
|
+
*/
|
|
80
|
+
importModelFile(args: Record<string, unknown>): Promise<{
|
|
81
|
+
content: Content[];
|
|
82
|
+
isError: boolean;
|
|
83
|
+
}>;
|
|
84
|
+
private allowed;
|
|
85
|
+
private announce;
|
|
86
|
+
private accept;
|
|
87
|
+
/** How long a token hello waits: 0 up to FREE_STRIKES_PER_MIN strikes in the last minute, then growing to MAX_DELAY_MS. */
|
|
88
|
+
delay(now?: number): number;
|
|
89
|
+
/** A wrong token proof: counted; MAX_WRONG of them replace the token, at most once per ROTATE_MS. */
|
|
90
|
+
private wrongProof;
|
|
91
|
+
private failAll;
|
|
92
|
+
/** Before pairing: challenge and refused only. */
|
|
93
|
+
private sendPlain;
|
|
94
|
+
/** To the paired tab: sealed with the session key and the next sequence number. */
|
|
95
|
+
private send;
|
|
96
|
+
}
|