@atropical/stroq-studio-mcp 0.1.0 → 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/README.md +49 -9
- package/dist/args.js +2 -2
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +25 -3
- package/dist/modelFile.d.ts +33 -0
- package/dist/modelFile.js +255 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.js +9 -0
- package/dist/relay.d.ts +11 -0
- package/dist/relay.js +39 -3
- package/package.json +36 -10
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# stroq-studio-mcp
|
|
2
2
|
|
|
3
|
-
Connects
|
|
3
|
+
Connects your AI agent (any MCP client: Claude Code, Codex, Cursor, Claude
|
|
4
|
+
Desktop and others) to an open
|
|
4
5
|
[Strōq World Studio](https://studio.stroq.club) tab, so the agent can build
|
|
5
6
|
cycling courses with the Studio's own tools: routes, road pieces, land,
|
|
6
7
|
landscapes, scenery, screenshots and your comments.
|
|
@@ -21,9 +22,16 @@ MCP client ⇄ stdio ⇄ stroq-studio-mcp ⇄ ws://127.0.0.1:3021 ⇄ Studio
|
|
|
21
22
|
claude mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
###
|
|
25
|
+
### Codex CLI
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
```sh
|
|
28
|
+
codex mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Claude Desktop, Cursor and other MCP clients
|
|
32
|
+
|
|
33
|
+
Add it to the client's MCP config (Claude Desktop: `claude_desktop_config.json`,
|
|
34
|
+
Settings → Developer → Edit Config; Cursor: `~/.cursor/mcp.json`):
|
|
27
35
|
|
|
28
36
|
```json
|
|
29
37
|
{
|
|
@@ -36,15 +44,15 @@ Add it to `claude_desktop_config.json` (Settings → Developer → Edit Config):
|
|
|
36
44
|
}
|
|
37
45
|
```
|
|
38
46
|
|
|
39
|
-
Then restart
|
|
47
|
+
Then restart the client.
|
|
40
48
|
|
|
41
49
|
### Connect the Studio
|
|
42
50
|
|
|
43
51
|
1. Start your agent. The relay prints one pairing code, `port:TOKEN`, such as
|
|
44
|
-
`3021:ABCDE-FGHIJ-KLMNO-PQRST-UVW234`, on stderr (
|
|
45
|
-
|
|
52
|
+
`3021:ABCDE-FGHIJ-KLMNO-PQRST-UVW234`, on stderr (most clients show the
|
|
53
|
+
server log). Or ask the agent to "connect to the Studio": its
|
|
46
54
|
`studio_connection` tool returns it.
|
|
47
|
-
2. In the Studio: **
|
|
55
|
+
2. In the Studio: **Tools → AI agent → Connect…**. Paste the code and switch
|
|
48
56
|
**Connect** on. The light turns on when it is connected. (A token pasted on
|
|
49
57
|
its own means port 3021.)
|
|
50
58
|
3. Ask the agent for something, e.g. "build a 10 km alpine climb with a village
|
|
@@ -84,6 +92,31 @@ tools appear (the relay sends `notifications/tools/list_changed`), for example
|
|
|
84
92
|
`resolve_comment` and `wait_for_comments`. While connected the agent owns the
|
|
85
93
|
Studio's comments.
|
|
86
94
|
|
|
95
|
+
`import_model_file` is the one tool the relay handles itself: it reads a model
|
|
96
|
+
from this computer (`path`, absolute; optional `name`, `category`, `tags`) and
|
|
97
|
+
sends it to the tab in 1 MB parts over the sealed connection; the tab imports
|
|
98
|
+
it into the Library the same way as the Library's own file import (optimised,
|
|
99
|
+
scaled, thumbnail) and answers with the new asset id and its thumbnail. It is
|
|
100
|
+
listed only while a connected tab offers it.
|
|
101
|
+
|
|
102
|
+
## Using it with Blender MCP
|
|
103
|
+
|
|
104
|
+
With [blender-mcp](https://github.com/ahujasid/blender-mcp) added alongside,
|
|
105
|
+
an agent can model something in Blender and put it straight into the Studio:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
claude mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp
|
|
109
|
+
claude mcp add blender -- uvx blender-mcp
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Open Blender with the blender-mcp add-on connected, connect the Studio, then
|
|
113
|
+
ask, for example: "Model a small stone well in Blender, export it as GLB to
|
|
114
|
+
/tmp/well.glb and add it to the Studio Library as a building." The agent
|
|
115
|
+
models it through Blender MCP, exports with
|
|
116
|
+
`bpy.ops.export_scene.gltf(filepath='/tmp/well.glb', export_format='GLB')`,
|
|
117
|
+
and calls `import_model_file` with that path. Model in metres, Z up (the glTF
|
|
118
|
+
exporter turns it Y up); the Studio scales it to a real height by category.
|
|
119
|
+
|
|
87
120
|
## Security
|
|
88
121
|
|
|
89
122
|
- Listens on `127.0.0.1` only; nothing on your network can connect.
|
|
@@ -108,8 +141,15 @@ Studio's comments.
|
|
|
108
141
|
- The resume key for reloads is derived from the proof on both sides and is
|
|
109
142
|
never sent.
|
|
110
143
|
- One tab at a time.
|
|
111
|
-
- It stores nothing and
|
|
112
|
-
|
|
144
|
+
- It stores nothing, and every tool runs inside the Studio tab, on the content
|
|
145
|
+
in that browser. The only files it reads are the ones you or your agent name
|
|
146
|
+
in `import_model_file`: a `.glb`, a `.gltf` or a `.zip`, at most 100 MB in
|
|
147
|
+
all. A glTF (also inside a zip) may bring only `.bin` buffers whose size
|
|
148
|
+
matches their `byteLength` and PNG, JPEG, WebP or KTX2 images whose first
|
|
149
|
+
bytes match their type, from its folder and at most two levels down; a zip
|
|
150
|
+
holds only those and the model. It refuses relative paths, symlinks, hidden
|
|
151
|
+
files or folders (a name starting with `.`) and anything whose real path is
|
|
152
|
+
outside the model's folder.
|
|
113
153
|
|
|
114
154
|
## Protocol
|
|
115
155
|
|
package/dist/args.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { DEFAULT_ORIGINS, DEFAULT_PORT } from './relay.js';
|
|
5
5
|
export const HELP = `Usage: stroq-studio-mcp [--port <n>] [--origin <url>]...
|
|
6
6
|
|
|
7
|
-
Connects
|
|
7
|
+
Connects any MCP client (Claude Code, Codex, Cursor, Claude Desktop…) to an open Strōq World Studio tab.
|
|
8
8
|
|
|
9
9
|
--port <n> Fixed WebSocket port on 127.0.0.1 (fails if busy). Default:
|
|
10
10
|
${DEFAULT_PORT}, or any free port when that one is taken
|
|
@@ -12,7 +12,7 @@ Connects an MCP client (Claude Code, Claude Desktop) to an open Strōq World Stu
|
|
|
12
12
|
the defaults (${DEFAULT_ORIGINS.join(', ')})
|
|
13
13
|
--help This text
|
|
14
14
|
|
|
15
|
-
The pairing string (port:TOKEN) is printed on stderr; paste it in the Studio under
|
|
15
|
+
The pairing string (port:TOKEN) is printed on stderr; paste it in the Studio under Tools → AI agent → Connect….`;
|
|
16
16
|
export function parseArgs(argv) {
|
|
17
17
|
let port;
|
|
18
18
|
const origins = [];
|
package/dist/mcp.d.ts
CHANGED
|
@@ -10,5 +10,36 @@ export declare const CONNECTION_TOOL: {
|
|
|
10
10
|
properties: {};
|
|
11
11
|
};
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
/** Handled here, not passed through: the relay reads the file and sends it to the tab in parts. */
|
|
14
|
+
export declare const IMPORT_TOOL: {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object";
|
|
19
|
+
properties: {
|
|
20
|
+
path: {
|
|
21
|
+
type: string;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
name: {
|
|
25
|
+
type: string;
|
|
26
|
+
description: string;
|
|
27
|
+
};
|
|
28
|
+
category: {
|
|
29
|
+
type: string;
|
|
30
|
+
enum: string[];
|
|
31
|
+
description: string;
|
|
32
|
+
};
|
|
33
|
+
tags: {
|
|
34
|
+
type: string;
|
|
35
|
+
items: {
|
|
36
|
+
type: string;
|
|
37
|
+
};
|
|
38
|
+
description: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
required: string[];
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export declare const INSTRUCTIONS = "Tools of an open Str\u014Dq World Studio tab, relayed. If only studio_connection is listed, no tab is connected yet: call it and tell the person the pairing string (port:TOKEN) to paste under Tools \u2192 AI agent \u2192 Connect\u2026. While connected this agent owns the Studio comments: ack_comment straight away, get_comment for the picture and thread, do the work, then reply_comment saying what changed.";
|
|
14
45
|
export declare function createMcpServer(relay: Relay): Server;
|
package/dist/mcp.js
CHANGED
|
@@ -4,20 +4,42 @@
|
|
|
4
4
|
/** The MCP side: the tools learnt from the tab, plus studio_connection (always there). */
|
|
5
5
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { MAX_MODEL_BYTES } from './modelFile.js';
|
|
8
|
+
import { IMPORT_FILE, IMPORT_PART } from './protocol.js';
|
|
7
9
|
export const VERSION = '0.1.0';
|
|
8
10
|
export const CONNECTION_TOOL = {
|
|
9
11
|
name: 'studio_connection',
|
|
10
|
-
description: 'Whether a Strōq World Studio tab is connected to this relay. When none is, it gives the pairing string (port:TOKEN) to tell the person: in the Studio,
|
|
12
|
+
description: 'Whether a Strōq World Studio tab is connected to this relay. When none is, it gives the pairing string (port:TOKEN) to tell the person: in the Studio, Tools → AI agent → Connect…, paste it, switch Connect on. The Studio tools appear once the tab connects.',
|
|
11
13
|
inputSchema: { type: 'object', properties: {} },
|
|
12
14
|
};
|
|
13
|
-
|
|
15
|
+
/** Handled here, not passed through: the relay reads the file and sends it to the tab in parts. */
|
|
16
|
+
export const IMPORT_TOOL = {
|
|
17
|
+
name: IMPORT_FILE,
|
|
18
|
+
description: `Adds a model file from this computer to the Studio Library (optimised, scaled, with a thumbnail). For a model made in Blender: export it as GLB (File → Export → glTF 2.0, format glb) to an absolute path, then call this. Takes .glb, .gltf (its buffers and textures from the same folder) or .zip, up to ${MAX_MODEL_BYTES / 1048576} MB; no symlinks. Returns the new asset id and its thumbnail.`,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
path: { type: 'string', description: 'Absolute path of the .glb, .gltf or .zip' },
|
|
23
|
+
name: { type: 'string', description: 'Name in the Library (default: from the file name)' },
|
|
24
|
+
category: { type: 'string', enum: ['prop', 'plant', 'rock', 'tree', 'building', 'vehicle', 'character'], description: 'Library category (default prop)' },
|
|
25
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Search tags' },
|
|
26
|
+
},
|
|
27
|
+
required: ['path'],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const HIDDEN = new Set([CONNECTION_TOOL.name, IMPORT_FILE, IMPORT_PART]);
|
|
31
|
+
export const INSTRUCTIONS = 'Tools of an open Strōq World Studio tab, relayed. If only studio_connection is listed, no tab is connected yet: call it and tell the person the pairing string (port:TOKEN) to paste under Tools → AI agent → Connect…. While connected this agent owns the Studio comments: ack_comment straight away, get_comment for the picture and thread, do the work, then reply_comment saying what changed.';
|
|
14
32
|
export function createMcpServer(relay) {
|
|
15
33
|
const server = new Server({ name: 'stroq-studio', version: VERSION }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
|
|
16
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [CONNECTION_TOOL, ...relay.tools.filter((t) => t.name
|
|
34
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [CONNECTION_TOOL, ...relay.tools.filter((t) => !HIDDEN.has(t.name)), ...(relay.canImport ? [IMPORT_TOOL] : [])] }));
|
|
17
35
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
18
36
|
const { name, arguments: args } = req.params;
|
|
19
37
|
if (name === CONNECTION_TOOL.name)
|
|
20
38
|
return { content: [{ type: 'text', text: JSON.stringify(relay.status(), null, 2) }] };
|
|
39
|
+
if (name === IMPORT_FILE)
|
|
40
|
+
return relay.importModelFile((args ?? {}));
|
|
41
|
+
if (name === IMPORT_PART)
|
|
42
|
+
return { content: [{ type: 'text', text: `Use ${IMPORT_FILE}` }], isError: true };
|
|
21
43
|
return relay.call(name, (args ?? {}));
|
|
22
44
|
});
|
|
23
45
|
return server;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare const MAX_MODEL_BYTES: number;
|
|
2
|
+
/** Bytes per chunk sent to the tab (base64 makes a frame about 1.4 MB). */
|
|
3
|
+
export declare const CHUNK_BYTES: number;
|
|
4
|
+
export declare const MODEL_EXTENSIONS: string[];
|
|
5
|
+
/** What a .gltf (or a zip) may carry beside the model. */
|
|
6
|
+
export declare const SIBLING_EXTENSIONS: string[];
|
|
7
|
+
/** Folders below the model's own that a sibling may sit in. */
|
|
8
|
+
export declare const MAX_DEPTH = 2;
|
|
9
|
+
export interface ModelFile {
|
|
10
|
+
/** Path relative to the model's folder, with forward slashes (the main file is its base name). */
|
|
11
|
+
name: string;
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
export interface Sibling {
|
|
15
|
+
path: string;
|
|
16
|
+
/** buffers[].byteLength, when the glTF names it as a buffer. */
|
|
17
|
+
byteLength?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class ModelFileError extends Error {
|
|
20
|
+
}
|
|
21
|
+
/** The files a .gltf names (buffers and images), checked; data: URIs are inside the file already. */
|
|
22
|
+
export declare function gltfSiblings(json: unknown): Sibling[];
|
|
23
|
+
/** An image's first bytes must match its type (a .env renamed .png is refused). */
|
|
24
|
+
export declare function checkImage(name: string, d: Uint8Array): void;
|
|
25
|
+
/**
|
|
26
|
+
* A zip's entries, read and checked without trusting it: only .glb/.gltf and
|
|
27
|
+
* the sibling types, no hidden segments, at most MAX_DEPTH folders down,
|
|
28
|
+
* stored or deflated, no encryption or zip64, within max. A .gltf inside is
|
|
29
|
+
* checked like one on disk (its siblings must be in the zip); images by type.
|
|
30
|
+
*/
|
|
31
|
+
export declare function checkZip(zip: Uint8Array, max?: number): void;
|
|
32
|
+
/** Reads a model for import, checked as above. Throws ModelFileError with a message for the agent. */
|
|
33
|
+
export declare function readModelFiles(path: string, max?: number): Promise<ModelFile[]>;
|
|
@@ -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
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -105,6 +105,15 @@ export type RelayMessage = {
|
|
|
105
105
|
} | {
|
|
106
106
|
type: 'pong';
|
|
107
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";
|
|
108
117
|
/** Largest frame the relay accepts from the tab (screenshots are big). */
|
|
109
118
|
export declare const MAX_FRAME: number;
|
|
110
119
|
/** A frame from the tab, checked; null when it is not one. */
|
package/dist/protocol.js
CHANGED
|
@@ -37,6 +37,15 @@ export const tokenKey = (t) => t.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
|
37
37
|
export const proofText = (who, port, tabNonce, relayNonce) => `${who}|${port}|${tabNonce}|${relayNonce}`;
|
|
38
38
|
export const macText = (dir, seq, body) => `${dir}|${seq}|${body}`;
|
|
39
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';
|
|
40
49
|
/** Largest frame the relay accepts from the tab (screenshots are big). */
|
|
41
50
|
export const MAX_FRAME = 32 * 1024 * 1024;
|
|
42
51
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
package/dist/relay.d.ts
CHANGED
|
@@ -70,6 +70,17 @@ export declare class Relay {
|
|
|
70
70
|
content: Content[];
|
|
71
71
|
isError: boolean;
|
|
72
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
|
+
}>;
|
|
73
84
|
private allowed;
|
|
74
85
|
private announce;
|
|
75
86
|
private accept;
|
package/dist/relay.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
17
|
import { WebSocketServer } from 'ws';
|
|
18
|
-
import {
|
|
18
|
+
import { CHUNK_BYTES, ModelFileError, readModelFiles } from './modelFile.js';
|
|
19
|
+
import { IMPORT_FILE, IMPORT_PART, MAX_FRAME, macText, PROTOCOL, parseTabMessage, proofText, tokenKey } from './protocol.js';
|
|
19
20
|
export const hmac = (key, text) => createHmac('sha256', key).update(text).digest('hex');
|
|
20
21
|
export const DEFAULT_PORT = 3021;
|
|
21
22
|
export const DEFAULT_ORIGINS = ['https://studio.stroq.club', 'http://localhost:3020'];
|
|
@@ -144,7 +145,7 @@ export class Relay {
|
|
|
144
145
|
call(name, args) {
|
|
145
146
|
const tab = this.tab;
|
|
146
147
|
if (!tab)
|
|
147
|
-
return Promise.resolve(err(`No Studio tab is connected. In the Studio:
|
|
148
|
+
return Promise.resolve(err(`No Studio tab is connected. In the Studio: Tools → AI agent → Connect… and paste ${this.pairing}.`));
|
|
148
149
|
if (!this.toolList.some((t) => t.name === name))
|
|
149
150
|
return Promise.resolve(err(`The Studio has no tool "${name}"`));
|
|
150
151
|
const id = this.nextId++;
|
|
@@ -157,6 +158,41 @@ export class Relay {
|
|
|
157
158
|
this.send(tab.ws, { type: 'call', id, name, arguments: args });
|
|
158
159
|
});
|
|
159
160
|
}
|
|
161
|
+
/** Whether the tab takes model files (it lists both import tools). */
|
|
162
|
+
get canImport() {
|
|
163
|
+
return !!this.tab && this.toolList.some((t) => t.name === IMPORT_FILE) && this.toolList.some((t) => t.name === IMPORT_PART);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* import_model_file: reads the model here (modelFile.ts checks), sends it to
|
|
167
|
+
* the tab in CHUNK_BYTES parts (one import_model_part call each, in order,
|
|
168
|
+
* each answered before the next), then asks the tab to import it.
|
|
169
|
+
*/
|
|
170
|
+
async importModelFile(args) {
|
|
171
|
+
if (!this.tab)
|
|
172
|
+
return this.call(IMPORT_FILE, {});
|
|
173
|
+
if (!this.canImport)
|
|
174
|
+
return err('This Studio tab cannot import model files: reload it, or update the Studio');
|
|
175
|
+
let files;
|
|
176
|
+
try {
|
|
177
|
+
files = await readModelFiles(args.path);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
return err(e instanceof ModelFileError ? e.message : `Could not read the model: ${e.message}`);
|
|
181
|
+
}
|
|
182
|
+
const upload = randomBytes(12).toString('hex');
|
|
183
|
+
for (const f of files) {
|
|
184
|
+
for (let offset = 0; offset < f.data.length || offset === 0; offset += CHUNK_BYTES) {
|
|
185
|
+
const data = Buffer.from(f.data.subarray(offset, offset + CHUNK_BYTES)).toString('base64');
|
|
186
|
+
const r = await this.call(IMPORT_PART, { upload, file: f.name, size: f.data.length, offset, data });
|
|
187
|
+
if (r.isError)
|
|
188
|
+
return r;
|
|
189
|
+
if (f.data.length === 0)
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const pick = (k) => (args[k] === undefined ? {} : { [k]: args[k] });
|
|
194
|
+
return this.call(IMPORT_FILE, { upload, files: files.map((f) => ({ name: f.name, size: f.data.length })), ...pick('name'), ...pick('category'), ...pick('tags') });
|
|
195
|
+
}
|
|
160
196
|
allowed(origin, req) {
|
|
161
197
|
// Only from this machine (the bind already ensures it) and only from the Studio's origins.
|
|
162
198
|
const host = req.socket.remoteAddress ?? '';
|
|
@@ -169,7 +205,7 @@ export class Relay {
|
|
|
169
205
|
return true;
|
|
170
206
|
}
|
|
171
207
|
announce() {
|
|
172
|
-
this.log(`Strōq Studio relay on ws://127.0.0.1:${this.port}. In the Studio:
|
|
208
|
+
this.log(`Strōq Studio relay on ws://127.0.0.1:${this.port}. In the Studio: Tools → AI agent → Connect… and paste ${this.pairing}`);
|
|
173
209
|
}
|
|
174
210
|
accept(ws, origin) {
|
|
175
211
|
let paired = false;
|
package/package.json
CHANGED
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atropical/stroq-studio-mcp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Connects
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Connects any MCP client (Claude Code, Codex, Cursor, Claude Desktop…) to an open Strōq World Studio tab, over a local WebSocket.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"author": "Atropical",
|
|
7
7
|
"homepage": "https://github.com/atropical/stroq-studio-mcp#readme",
|
|
8
|
-
"repository": {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/atropical/stroq-studio-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/atropical/stroq-studio-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"stroq",
|
|
19
|
+
"world-studio",
|
|
20
|
+
"claude",
|
|
21
|
+
"codex",
|
|
22
|
+
"cursor"
|
|
23
|
+
],
|
|
11
24
|
"type": "module",
|
|
12
|
-
"bin": {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
25
|
+
"bin": {
|
|
26
|
+
"stroq-studio-mcp": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/relay.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist/*.js",
|
|
33
|
+
"dist/*.d.ts",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
17
43
|
"scripts": {
|
|
18
44
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
19
45
|
"type-check": "tsc --noEmit",
|