@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
package/README.md
CHANGED
|
@@ -1,3 +1,198 @@
|
|
|
1
|
-
#
|
|
1
|
+
# stroq-studio-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
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
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[]>;
|