@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/README.md CHANGED
@@ -1,3 +1,198 @@
1
- # Temporary Holding Version
1
+ # stroq-studio-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connects your AI agent (any MCP client: Claude Code, Codex, Cursor, Claude
4
+ Desktop and others) to an open
5
+ [Strōq World Studio](https://studio.stroq.club) tab, so the agent can build
6
+ cycling courses with the Studio's own tools: routes, road pieces, land,
7
+ landscapes, scenery, screenshots and your comments.
8
+
9
+ It is a thin relay. The MCP client starts it over stdio; it opens a WebSocket
10
+ on `127.0.0.1` that the Studio tab connects to. The tools come from the tab
11
+ itself, so they always match the Studio you have open.
12
+
13
+ ```
14
+ MCP client ⇄ stdio ⇄ stroq-studio-mcp ⇄ ws://127.0.0.1:3021 ⇄ Studio tab
15
+ ```
16
+
17
+ ## Set up
18
+
19
+ ### Claude Code
20
+
21
+ ```sh
22
+ claude mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp
23
+ ```
24
+
25
+ ### Codex CLI
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`):
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "stroq-studio": {
40
+ "command": "npx",
41
+ "args": ["-y", "@atropical/stroq-studio-mcp"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ Then restart the client.
48
+
49
+ ### Connect the Studio
50
+
51
+ 1. Start your agent. The relay prints one pairing code, `port:TOKEN`, such as
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
54
+ `studio_connection` tool returns it.
55
+ 2. In the Studio: **Tools → AI agent → Connect…**. Paste the code and switch
56
+ **Connect** on. The light turns on when it is connected. (A token pasted on
57
+ its own means port 3021.)
58
+ 3. Ask the agent for something, e.g. "build a 10 km alpine climb with a village
59
+ at the top".
60
+
61
+ A code is used up once a tab pairs with it. Reloading the same tab reconnects
62
+ by itself; another tab, or a new relay, needs a new code (the relay prints one
63
+ each time a tab disconnects).
64
+
65
+ Your browser may ask whether the Studio may reach devices on this computer or
66
+ local network: allow it, or the tab cannot reach the relay.
67
+
68
+ ## Options
69
+
70
+ ```
71
+ stroq-studio-mcp [--port <n>] [--origin <url>]...
72
+
73
+ --port <n> Fixed WebSocket port on 127.0.0.1 (fails if busy). Default:
74
+ 3021, or any free port when that one is taken (said on stderr;
75
+ the pairing code always carries the port)
76
+ --origin <url> A Studio origin allowed to connect; repeat for more. Replaces
77
+ the defaults (https://studio.stroq.club, http://localhost:3020)
78
+ ```
79
+
80
+ No port setting is needed: the pairing code carries it. To fix one, add it to
81
+ the MCP config, e.g.
82
+ `claude mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp --port 3031`.
83
+
84
+ ## Tools
85
+
86
+ Until a tab is connected the relay lists one tool, `studio_connection`
87
+ (connection state and the pairing code). Once a tab connects, the tab's
88
+ tools appear (the relay sends `notifications/tools/list_changed`), for example
89
+ `list_routes`, `put_route`, `validate_route`, `scatter`, `apply_landscape`,
90
+ `screenshot` (as an image), and the comment tools `list_comments`,
91
+ `get_comment` (with its screenshot), `ack_comment`, `reply_comment`,
92
+ `resolve_comment` and `wait_for_comments`. While connected the agent owns the
93
+ Studio's comments.
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
+
120
+ ## Security
121
+
122
+ - Listens on `127.0.0.1` only; nothing on your network can connect.
123
+ - Accepts a WebSocket only from an allowed `Origin` (the Studio's addresses
124
+ above, or the ones you pass with `--origin`), so other web pages cannot use
125
+ it.
126
+ - The pairing token is 26 base32 characters (about 130 bits), far too many to
127
+ guess offline from the relay's proof. Five wrong proofs replace it with a
128
+ new one (printed on stderr), at most once a minute.
129
+ - Nothing on the machine can block pairing: once more than 30 handshakes in a
130
+ minute have failed or been left unfinished, token hellos are answered late
131
+ (growing, at most 5 seconds, with a `wait` frame first). Successful
132
+ pairings never count, and resume hellos (a 256-bit key) never wait.
133
+ - Pairing is mutual and bound to the port: the tab and the relay each prove
134
+ they know the token with an HMAC-SHA256 over two fresh nonces and the port
135
+ the tab dialled, which the relay checks against the port it listens on. The
136
+ token never crosses the wire, the tab trusts no other process on the port,
137
+ and a relay in the middle on another port cannot pass the proofs on.
138
+ - After pairing every frame, both ways, carries an HMAC with the session key
139
+ derived from the proof and a sequence number per direction; a changed,
140
+ replayed or out-of-order frame closes the connection.
141
+ - The resume key for reloads is derived from the proof on both sides and is
142
+ never sent.
143
+ - One tab at a time.
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.
153
+
154
+ ## Protocol
155
+
156
+ JSON text frames over one WebSocket (see `src/protocol.ts`). The key is the
157
+ token (upper case, letters and digits) or the resume key from the last
158
+ pairing; `port` is the port the tab dialled; nonces are 32 random bytes in hex;
159
+ proofs and MACs are hex HMAC-SHA256.
160
+
161
+ | Direction | Frame |
162
+ | --- | --- |
163
+ | tab → relay | `{ "type": "hello", "protocol": 3, "mode": "token" or "resume", "port": 3021, "nonce": tn }` |
164
+ | relay → tab | `{ "type": "challenge", "nonce": rn, "proof": HMAC(key, "relay\|port\|tn\|rn") }` or `{ "type": "refused", "reason": "…" }`, maybe after `{ "type": "wait", "ms": n }` |
165
+ | tab → relay | `{ "type": "prove", "proof": HMAC(key, "tab\|port\|tn\|rn") }` (only after checking the relay's proof) |
166
+
167
+ Both then hold `session = HMAC(key, "session|port|tn|rn")` and
168
+ `resume = HMAC(key, "resume|port|tn|rn")`. Every later frame is
169
+ `{ "type": "sealed", "seq": n, "body": "<frame JSON>", "mac": HMAC(session, dir + "|" + n + "|" + body) }`,
170
+ `dir` being `relay` or `tab` and `seq` counting from 0 in each direction.
171
+ Sealed bodies:
172
+
173
+ | Direction | Body |
174
+ | --- | --- |
175
+ | relay → tab | `{ "type": "welcome", "protocol": 3 }` (the first) |
176
+ | tab → relay | `{ "type": "tools", "tools": [{ "name", "description", "inputSchema" }] }` |
177
+ | relay → tab | `{ "type": "call", "id": 1, "name": "list_routes", "arguments": {} }` |
178
+ | tab → relay | `{ "type": "result", "id": 1, "content": [MCP text/image blocks], "isError": false }` |
179
+ | either | `{ "type": "ping" }` / `{ "type": "pong" }` |
180
+
181
+ ## Develop
182
+
183
+ ```sh
184
+ bun install
185
+ bun test
186
+ npm run build # dist/
187
+ npm run type-check
188
+ ```
189
+
190
+ ## Licence
191
+
192
+ Copyright (C) 2026 Atropical.
193
+
194
+ This program is free software: you can redistribute it and/or modify it under
195
+ the terms of the GNU General Public License as published by the Free Software
196
+ Foundation, version 3 only. It is distributed in the hope that it will be
197
+ useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
198
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See [LICENSE](LICENSE).
package/dist/args.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare const HELP: string;
2
+ export declare function parseArgs(argv: string[]): {
3
+ port?: number;
4
+ origins: string[];
5
+ help: boolean;
6
+ };
package/dist/args.js ADDED
@@ -0,0 +1,56 @@
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
+ import { DEFAULT_ORIGINS, DEFAULT_PORT } from './relay.js';
5
+ export const HELP = `Usage: stroq-studio-mcp [--port <n>] [--origin <url>]...
6
+
7
+ Connects any MCP client (Claude Code, Codex, Cursor, Claude Desktop…) to an open Strōq World Studio tab.
8
+
9
+ --port <n> Fixed WebSocket port on 127.0.0.1 (fails if busy). Default:
10
+ ${DEFAULT_PORT}, or any free port when that one is taken
11
+ --origin <url> A Studio origin allowed to connect; repeat for more. Replaces
12
+ the defaults (${DEFAULT_ORIGINS.join(', ')})
13
+ --help This text
14
+
15
+ The pairing string (port:TOKEN) is printed on stderr; paste it in the Studio under Tools → AI agent → Connect….`;
16
+ export function parseArgs(argv) {
17
+ let port;
18
+ const origins = [];
19
+ let help = false;
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ const eq = a.startsWith('--') ? a.indexOf('=') : -1;
23
+ const flag = eq > 0 ? a.slice(0, eq) : a;
24
+ const inline = eq > 0 ? a.slice(eq + 1) : undefined;
25
+ const value = () => {
26
+ const v = inline ?? argv[++i];
27
+ if (v === undefined)
28
+ throw new Error(`${flag} needs a value`);
29
+ return v;
30
+ };
31
+ if (flag === '--help' || flag === '-h')
32
+ help = true;
33
+ else if (flag === '--port') {
34
+ const n = Number(value());
35
+ port = n;
36
+ if (!Number.isInteger(n) || n < 1024 || n > 65535)
37
+ throw new Error('--port must be a whole number from 1024 to 65535');
38
+ }
39
+ else if (flag === '--origin') {
40
+ const o = value();
41
+ let u;
42
+ try {
43
+ u = new URL(o);
44
+ }
45
+ catch {
46
+ throw new Error(`--origin "${o}" is not a URL`);
47
+ }
48
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
49
+ throw new Error(`--origin "${o}" must be http or https`);
50
+ origins.push(u.origin);
51
+ }
52
+ else
53
+ throw new Error(`Unknown option "${a}" (try --help)`);
54
+ }
55
+ return { port, origins: origins.length ? origins : [...DEFAULT_ORIGINS], help };
56
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // stroq-studio-mcp: the relay between an MCP client and a Strōq World Studio tab.
3
+ // Copyright (C) 2026 Atropical
4
+ // SPDX-License-Identifier: GPL-3.0-only
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { HELP, parseArgs } from './args.js';
7
+ import { createMcpServer, VERSION } from './mcp.js';
8
+ import { Relay } from './relay.js';
9
+ const log = (line) => process.stderr.write(`[stroq-studio] ${line}\n`);
10
+ let opts;
11
+ try {
12
+ opts = parseArgs(process.argv.slice(2));
13
+ }
14
+ catch (e) {
15
+ log(e.message);
16
+ process.exit(2);
17
+ }
18
+ if (opts.help) {
19
+ process.stderr.write(`stroq-studio-mcp ${VERSION}\n${HELP}\n`);
20
+ process.exit(0);
21
+ }
22
+ let live = false;
23
+ const relay = new Relay({
24
+ port: opts.port,
25
+ origins: opts.origins,
26
+ log,
27
+ onToolsChanged: () => {
28
+ if (live)
29
+ server.sendToolListChanged().catch(() => undefined);
30
+ },
31
+ });
32
+ const server = createMcpServer(relay);
33
+ server.oninitialized = () => {
34
+ live = true;
35
+ };
36
+ try {
37
+ await relay.start();
38
+ }
39
+ catch (e) {
40
+ log(`Could not listen on 127.0.0.1:${opts.port ?? relay.port}: ${e.message}.${opts.port ? ' Is another relay running on it? Pick another --port, or leave --port out to use any free port.' : ''}`);
41
+ process.exit(1);
42
+ }
43
+ await server.connect(new StdioServerTransport());
44
+ const stop = () => {
45
+ relay.stop().finally(() => process.exit(0));
46
+ };
47
+ process.stdin.on('close', stop);
48
+ process.on('SIGINT', stop);
49
+ process.on('SIGTERM', stop);
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /** The MCP side: the tools learnt from the tab, plus studio_connection (always there). */
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import type { Relay } from './relay.js';
4
+ export declare const VERSION = "0.1.0";
5
+ export declare const CONNECTION_TOOL: {
6
+ name: string;
7
+ description: string;
8
+ inputSchema: {
9
+ type: "object";
10
+ properties: {};
11
+ };
12
+ };
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.";
45
+ export declare function createMcpServer(relay: Relay): Server;
package/dist/mcp.js ADDED
@@ -0,0 +1,46 @@
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
+ /** The MCP side: the tools learnt from the tab, plus studio_connection (always there). */
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
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';
9
+ export const VERSION = '0.1.0';
10
+ export const CONNECTION_TOOL = {
11
+ name: 'studio_connection',
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.',
13
+ inputSchema: { type: 'object', properties: {} },
14
+ };
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.';
32
+ export function createMcpServer(relay) {
33
+ const server = new Server({ name: 'stroq-studio', version: VERSION }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
34
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [CONNECTION_TOOL, ...relay.tools.filter((t) => !HIDDEN.has(t.name)), ...(relay.canImport ? [IMPORT_TOOL] : [])] }));
35
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
36
+ const { name, arguments: args } = req.params;
37
+ if (name === CONNECTION_TOOL.name)
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 };
43
+ return relay.call(name, (args ?? {}));
44
+ });
45
+ return server;
46
+ }
@@ -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[]>;