@atropical/stroq-studio-mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,158 @@
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 an AI agent (Claude Code, Claude Desktop or any MCP client) to an open
4
+ [Strōq World Studio](https://studio.stroq.club) tab, so the agent can build
5
+ cycling courses with the Studio's own tools: routes, road pieces, land,
6
+ landscapes, scenery, screenshots and your comments.
7
+
8
+ It is a thin relay. The MCP client starts it over stdio; it opens a WebSocket
9
+ on `127.0.0.1` that the Studio tab connects to. The tools come from the tab
10
+ itself, so they always match the Studio you have open.
11
+
12
+ ```
13
+ MCP client ⇄ stdio ⇄ stroq-studio-mcp ⇄ ws://127.0.0.1:3021 ⇄ Studio tab
14
+ ```
15
+
16
+ ## Set up
17
+
18
+ ### Claude Code
19
+
20
+ ```sh
21
+ claude mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp
22
+ ```
23
+
24
+ ### Claude Desktop
25
+
26
+ Add it to `claude_desktop_config.json` (Settings → Developer → Edit Config):
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "stroq-studio": {
32
+ "command": "npx",
33
+ "args": ["-y", "@atropical/stroq-studio-mcp"]
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Then restart Claude Desktop.
40
+
41
+ ### Connect the Studio
42
+
43
+ 1. Start your agent. The relay prints one pairing code, `port:TOKEN`, such as
44
+ `3021:ABCDE-FGHIJ-KLMNO-PQRST-UVW234`, on stderr (Claude Code: `/mcp` shows
45
+ the server log). Or ask the agent to "connect to the Studio": its
46
+ `studio_connection` tool returns it.
47
+ 2. In the Studio: **Help → Connect an agent**. Paste the code and switch
48
+ **Connect** on. The light turns on when it is connected. (A token pasted on
49
+ its own means port 3021.)
50
+ 3. Ask the agent for something, e.g. "build a 10 km alpine climb with a village
51
+ at the top".
52
+
53
+ A code is used up once a tab pairs with it. Reloading the same tab reconnects
54
+ by itself; another tab, or a new relay, needs a new code (the relay prints one
55
+ each time a tab disconnects).
56
+
57
+ Your browser may ask whether the Studio may reach devices on this computer or
58
+ local network: allow it, or the tab cannot reach the relay.
59
+
60
+ ## Options
61
+
62
+ ```
63
+ stroq-studio-mcp [--port <n>] [--origin <url>]...
64
+
65
+ --port <n> Fixed WebSocket port on 127.0.0.1 (fails if busy). Default:
66
+ 3021, or any free port when that one is taken (said on stderr;
67
+ the pairing code always carries the port)
68
+ --origin <url> A Studio origin allowed to connect; repeat for more. Replaces
69
+ the defaults (https://studio.stroq.club, http://localhost:3020)
70
+ ```
71
+
72
+ No port setting is needed: the pairing code carries it. To fix one, add it to
73
+ the MCP config, e.g.
74
+ `claude mcp add stroq-studio -- npx -y @atropical/stroq-studio-mcp --port 3031`.
75
+
76
+ ## Tools
77
+
78
+ Until a tab is connected the relay lists one tool, `studio_connection`
79
+ (connection state and the pairing code). Once a tab connects, the tab's
80
+ tools appear (the relay sends `notifications/tools/list_changed`), for example
81
+ `list_routes`, `put_route`, `validate_route`, `scatter`, `apply_landscape`,
82
+ `screenshot` (as an image), and the comment tools `list_comments`,
83
+ `get_comment` (with its screenshot), `ack_comment`, `reply_comment`,
84
+ `resolve_comment` and `wait_for_comments`. While connected the agent owns the
85
+ Studio's comments.
86
+
87
+ ## Security
88
+
89
+ - Listens on `127.0.0.1` only; nothing on your network can connect.
90
+ - Accepts a WebSocket only from an allowed `Origin` (the Studio's addresses
91
+ above, or the ones you pass with `--origin`), so other web pages cannot use
92
+ it.
93
+ - The pairing token is 26 base32 characters (about 130 bits), far too many to
94
+ guess offline from the relay's proof. Five wrong proofs replace it with a
95
+ new one (printed on stderr), at most once a minute.
96
+ - Nothing on the machine can block pairing: once more than 30 handshakes in a
97
+ minute have failed or been left unfinished, token hellos are answered late
98
+ (growing, at most 5 seconds, with a `wait` frame first). Successful
99
+ pairings never count, and resume hellos (a 256-bit key) never wait.
100
+ - Pairing is mutual and bound to the port: the tab and the relay each prove
101
+ they know the token with an HMAC-SHA256 over two fresh nonces and the port
102
+ the tab dialled, which the relay checks against the port it listens on. The
103
+ token never crosses the wire, the tab trusts no other process on the port,
104
+ and a relay in the middle on another port cannot pass the proofs on.
105
+ - After pairing every frame, both ways, carries an HMAC with the session key
106
+ derived from the proof and a sequence number per direction; a changed,
107
+ replayed or out-of-order frame closes the connection.
108
+ - The resume key for reloads is derived from the proof on both sides and is
109
+ never sent.
110
+ - One tab at a time.
111
+ - It stores nothing and reads no files: every tool runs inside the Studio tab,
112
+ on the content in that browser.
113
+
114
+ ## Protocol
115
+
116
+ JSON text frames over one WebSocket (see `src/protocol.ts`). The key is the
117
+ token (upper case, letters and digits) or the resume key from the last
118
+ pairing; `port` is the port the tab dialled; nonces are 32 random bytes in hex;
119
+ proofs and MACs are hex HMAC-SHA256.
120
+
121
+ | Direction | Frame |
122
+ | --- | --- |
123
+ | tab → relay | `{ "type": "hello", "protocol": 3, "mode": "token" or "resume", "port": 3021, "nonce": tn }` |
124
+ | relay → tab | `{ "type": "challenge", "nonce": rn, "proof": HMAC(key, "relay\|port\|tn\|rn") }` or `{ "type": "refused", "reason": "…" }`, maybe after `{ "type": "wait", "ms": n }` |
125
+ | tab → relay | `{ "type": "prove", "proof": HMAC(key, "tab\|port\|tn\|rn") }` (only after checking the relay's proof) |
126
+
127
+ Both then hold `session = HMAC(key, "session|port|tn|rn")` and
128
+ `resume = HMAC(key, "resume|port|tn|rn")`. Every later frame is
129
+ `{ "type": "sealed", "seq": n, "body": "<frame JSON>", "mac": HMAC(session, dir + "|" + n + "|" + body) }`,
130
+ `dir` being `relay` or `tab` and `seq` counting from 0 in each direction.
131
+ Sealed bodies:
132
+
133
+ | Direction | Body |
134
+ | --- | --- |
135
+ | relay → tab | `{ "type": "welcome", "protocol": 3 }` (the first) |
136
+ | tab → relay | `{ "type": "tools", "tools": [{ "name", "description", "inputSchema" }] }` |
137
+ | relay → tab | `{ "type": "call", "id": 1, "name": "list_routes", "arguments": {} }` |
138
+ | tab → relay | `{ "type": "result", "id": 1, "content": [MCP text/image blocks], "isError": false }` |
139
+ | either | `{ "type": "ping" }` / `{ "type": "pong" }` |
140
+
141
+ ## Develop
142
+
143
+ ```sh
144
+ bun install
145
+ bun test
146
+ npm run build # dist/
147
+ npm run type-check
148
+ ```
149
+
150
+ ## Licence
151
+
152
+ Copyright (C) 2026 Atropical.
153
+
154
+ This program is free software: you can redistribute it and/or modify it under
155
+ the terms of the GNU General Public License as published by the Free Software
156
+ Foundation, version 3 only. It is distributed in the hope that it will be
157
+ useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
158
+ 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 an MCP client (Claude Code, 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 Help → Connect an agent.`;
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,14 @@
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
+ 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 Help \u2192 Connect an agent. 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
+ export declare function createMcpServer(relay: Relay): Server;
package/dist/mcp.js ADDED
@@ -0,0 +1,24 @@
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
+ export const VERSION = '0.1.0';
8
+ export const CONNECTION_TOOL = {
9
+ 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, Help → Connect an agent, paste it, switch Connect on. The Studio tools appear once the tab connects.',
11
+ inputSchema: { type: 'object', properties: {} },
12
+ };
13
+ 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 Help → Connect an agent. 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
+ export function createMcpServer(relay) {
15
+ 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 !== CONNECTION_TOOL.name)] }));
17
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
18
+ const { name, arguments: args } = req.params;
19
+ if (name === CONNECTION_TOOL.name)
20
+ return { content: [{ type: 'text', text: JSON.stringify(relay.status(), null, 2) }] };
21
+ return relay.call(name, (args ?? {}));
22
+ });
23
+ return server;
24
+ }
@@ -0,0 +1,111 @@
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
+ /** Largest frame the relay accepts from the tab (screenshots are big). */
109
+ export declare const MAX_FRAME: number;
110
+ /** A frame from the tab, checked; null when it is not one. */
111
+ export declare function parseTabMessage(raw: string): TabMessage | null;
@@ -0,0 +1,99 @@
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
+ /** Largest frame the relay accepts from the tab (screenshots are big). */
41
+ export const MAX_FRAME = 32 * 1024 * 1024;
42
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
43
+ function isContent(v) {
44
+ if (!isObj(v))
45
+ return false;
46
+ if (v.type === 'text')
47
+ return typeof v.text === 'string';
48
+ if (v.type === 'image')
49
+ return typeof v.data === 'string' && typeof v.mimeType === 'string' && /^image\/(png|jpeg|webp|gif)$/.test(v.mimeType);
50
+ return false;
51
+ }
52
+ function isTool(v) {
53
+ return (isObj(v) &&
54
+ typeof v.name === 'string' &&
55
+ /^[A-Za-z0-9_-]{1,64}$/.test(v.name) &&
56
+ (v.description === undefined || typeof v.description === 'string') &&
57
+ isObj(v.inputSchema) &&
58
+ v.inputSchema.type === 'object');
59
+ }
60
+ /** A frame from the tab, checked; null when it is not one. */
61
+ export function parseTabMessage(raw) {
62
+ let v;
63
+ try {
64
+ v = JSON.parse(raw);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ if (!isObj(v))
70
+ return null;
71
+ switch (v.type) {
72
+ case 'hello':
73
+ if (typeof v.protocol !== 'number')
74
+ return null;
75
+ // Another protocol version gets a clear refusal, whatever else it sent.
76
+ if (v.protocol !== PROTOCOL)
77
+ return { type: 'hello', protocol: v.protocol, mode: 'token', port: 0, nonce: '' };
78
+ return (v.mode === 'token' || v.mode === 'resume') && Number.isInteger(v.port) && typeof v.nonce === 'string' && HEX64.test(v.nonce)
79
+ ? { type: 'hello', protocol: v.protocol, mode: v.mode, port: v.port, nonce: v.nonce }
80
+ : null;
81
+ case 'sealed':
82
+ return Number.isInteger(v.seq) && v.seq >= 0 && typeof v.body === 'string' && typeof v.mac === 'string' && HEX64.test(v.mac)
83
+ ? { type: 'sealed', seq: v.seq, body: v.body, mac: v.mac }
84
+ : null;
85
+ case 'prove':
86
+ return typeof v.proof === 'string' && HEX64.test(v.proof) ? { type: 'prove', proof: v.proof } : null;
87
+ case 'tools':
88
+ return Array.isArray(v.tools) && v.tools.length <= 500 && v.tools.every(isTool) ? { type: 'tools', tools: v.tools } : null;
89
+ case 'result':
90
+ return Number.isInteger(v.id) && Array.isArray(v.content) && v.content.every(isContent)
91
+ ? { type: 'result', id: v.id, content: v.content, isError: v.isError === true }
92
+ : null;
93
+ case 'ping':
94
+ case 'pong':
95
+ return { type: v.type };
96
+ default:
97
+ return null;
98
+ }
99
+ }
@@ -0,0 +1,85 @@
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
+ private allowed;
74
+ private announce;
75
+ private accept;
76
+ /** How long a token hello waits: 0 up to FREE_STRIKES_PER_MIN strikes in the last minute, then growing to MAX_DELAY_MS. */
77
+ delay(now?: number): number;
78
+ /** A wrong token proof: counted; MAX_WRONG of them replace the token, at most once per ROTATE_MS. */
79
+ private wrongProof;
80
+ private failAll;
81
+ /** Before pairing: challenge and refused only. */
82
+ private sendPlain;
83
+ /** To the paired tab: sealed with the session key and the next sequence number. */
84
+ private send;
85
+ }