@synkit/nexus 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 +21 -0
- package/README.md +134 -0
- package/dist/index.js +549 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Synkit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @synkit/nexus
|
|
2
|
+
|
|
3
|
+
> Drive the [Nexus](https://nexus.synkit.net) procedural-design tool from any AI agent that speaks MCP.
|
|
4
|
+
|
|
5
|
+
`@synkit/nexus` is a [Model Context Protocol](https://modelcontextprotocol.io) server. It bridges your agent (Claude Code, Claude Desktop, Cursor, any MCP client) to a Nexus editor tab running in your browser, so the agent can read the canvas, create generators, tune parameters, save snapshots — all the things you'd otherwise do by hand in the inspector.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌──────────────┐ stdio ┌────────────────┐ ws://127.0.0.1 ┌──────────────────┐
|
|
9
|
+
│ Your agent │ <──────> │ MCP server │ <────────────────> │ Nexus editor │
|
|
10
|
+
│ (MCP host) │ │ (this pkg) │ │ (browser tab) │
|
|
11
|
+
└──────────────┘ └────────────────┘ └──────────────────┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The server is a thin router: it tells the agent what tools are available, forwards each call to the editor over a local WebSocket, and returns the result. The actual logic — every layer mutation, every snapshot, every store read — lives in the editor.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
You don't install anything ahead of time. Your MCP client invokes `@synkit/nexus` via `npx`, which fetches and runs the latest version on demand.
|
|
19
|
+
|
|
20
|
+
### Claude Code
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
claude mcp add nexus -- npx @synkit/nexus
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then restart Claude Code.
|
|
27
|
+
|
|
28
|
+
### Cursor
|
|
29
|
+
|
|
30
|
+
Add to `~/.cursor/mcp.json` (create if missing):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"nexus": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["@synkit/nexus"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Restart Cursor.
|
|
44
|
+
|
|
45
|
+
### Claude Desktop
|
|
46
|
+
|
|
47
|
+
Add to your config file:
|
|
48
|
+
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
49
|
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"nexus": {
|
|
55
|
+
"command": "npx",
|
|
56
|
+
"args": ["@synkit/nexus"]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Restart Claude Desktop.
|
|
63
|
+
|
|
64
|
+
### Other MCP clients
|
|
65
|
+
|
|
66
|
+
Any client that speaks the stdio MCP transport works. Invoke `npx @synkit/nexus` as the server command.
|
|
67
|
+
|
|
68
|
+
## Open the editor
|
|
69
|
+
|
|
70
|
+
The agent needs a Nexus tab to connect to.
|
|
71
|
+
|
|
72
|
+
1. Go to [nexus.synkit.net](https://nexus.synkit.net) (or your local dev build).
|
|
73
|
+
2. Open the **MCP** menu from the top bar — you'll see your agent connect once the bridge is running.
|
|
74
|
+
|
|
75
|
+
The first time an HTTPS page (`nexus.synkit.net`) reaches the local bridge, Chrome will show a **Private Network Access** prompt asking you to allow the connection. Approve it once; subsequent sessions auto-allow.
|
|
76
|
+
|
|
77
|
+
## What the agent can do
|
|
78
|
+
|
|
79
|
+
| Tool | What it does |
|
|
80
|
+
|------|--------------|
|
|
81
|
+
| `ping` | Health check — confirms the editor is connected. |
|
|
82
|
+
| `get_canvas_state` | Full read-only snapshot of every layer (id, type, bounds, parameters, library). |
|
|
83
|
+
| `list_generator_types` | The canonical generator type strings. |
|
|
84
|
+
| `add_layer` | Create a new layer (any generator or shape type). |
|
|
85
|
+
| `list_array_variant_presets` | Built-in shape presets for Array generators. |
|
|
86
|
+
| `add_array_variants` | Inject preset shapes into an Array's library. |
|
|
87
|
+
| `set_array_variants` | Replace an Array's library wholesale with curated shapes. |
|
|
88
|
+
| `update_layer_param` | Change one parameter on one layer. |
|
|
89
|
+
| `update_layer_params` | Change many parameters on one layer in a single render. |
|
|
90
|
+
| `bulk_update_layers` | Apply ops across many layers in a single store mutation — the fast path for grid configurations. |
|
|
91
|
+
| `duplicate_layer` | Clone a layer by id. |
|
|
92
|
+
| `set_layer_bounds` | Move and/or resize a layer (partial bounds OK). |
|
|
93
|
+
| `remove_layer` | Delete a layer. |
|
|
94
|
+
| `save_canvas_snapshot` | Save the current canvas to a named localStorage slot. |
|
|
95
|
+
| `restore_canvas_snapshot` | Restore a saved snapshot as a single undo checkpoint. |
|
|
96
|
+
| `list_canvas_snapshots` | Enumerate saved snapshots. |
|
|
97
|
+
| `delete_canvas_snapshot` | Delete a named snapshot. |
|
|
98
|
+
|
|
99
|
+
Each tool has a long-form description visible to the agent — you can ask "what can you do with the canvas" and it'll enumerate.
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
| Env var | Default | Notes |
|
|
104
|
+
|---------|---------|-------|
|
|
105
|
+
| `NEXUS_MCP_PORT` | `7811` | Port the WebSocket bridge listens on (always bound to `127.0.0.1`). |
|
|
106
|
+
| `NEXUS_MCP_ALLOWED_ORIGINS` | _(unset)_ | Comma-separated extra origins allowed to reach the bridge. Localhost + `*.synkit.net` are allowed by default; set this if you're hosting Nexus on a custom domain. Example: `https://my-nexus.example.com,https://staging.example.com`. |
|
|
107
|
+
|
|
108
|
+
## Security
|
|
109
|
+
|
|
110
|
+
- **Localhost-only.** The bridge binds to `127.0.0.1`. Random sites on the internet can't reach it. Anyone with code running on your machine who can open a localhost socket can drive your editor — but at that point they could already do anything else with your account too.
|
|
111
|
+
- **Origin allowlist.** Browser-side requests are CORS-locked to `localhost`, `127.0.0.1`, and `*.synkit.net`. A drive-by site (`https://evil.com`) gets the preflight back without `Access-Control-Allow-Origin`, so the request is blocked — even if you accidentally accepted Chrome's Private Network Access prompt for that origin. Extend the list with `NEXUS_MCP_ALLOWED_ORIGINS` if you host Nexus elsewhere.
|
|
112
|
+
- **No auth.** The bridge has no token. If you share a screen-recording or your machine is otherwise compromised, anyone with shell access can run `curl http://127.0.0.1:7811/call`.
|
|
113
|
+
- **Single-tenant.** At most one editor tab connects at a time. Opening a second tab disconnects the first.
|
|
114
|
+
- **You control sessions.** The Nexus editor has a Disconnect button in the MCP menu — it closes the WebSocket and refuses to reconnect until you click Resume. Use this if you want to step away with the editor open but no agent attached.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
If you're hacking on the server itself (rather than just using it):
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
git clone https://github.com/synkit/nexus.git
|
|
122
|
+
cd nexus/mcp
|
|
123
|
+
npm install
|
|
124
|
+
npm run dev # tsx watch + stdio
|
|
125
|
+
npm run bridge # bridge only, no MCP stdio — for curl/wscat
|
|
126
|
+
npm run typecheck # tsc --noEmit
|
|
127
|
+
npm run build # tsup → ./dist
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The Nexus-side handlers live in `src/lib/agentic/tool-registry.ts` (in the main repo). Add a handler there, add a catalog entry in `mcp/src/index.ts`, done.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
|
|
11
|
+
// src/bridge.ts
|
|
12
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
13
|
+
import { createServer } from "http";
|
|
14
|
+
|
|
15
|
+
// src/protocol.ts
|
|
16
|
+
var BRIDGE_PROTOCOL_VERSION = 2;
|
|
17
|
+
|
|
18
|
+
// src/bridge.ts
|
|
19
|
+
var DEFAULT_ALLOWED_ORIGIN_PATTERNS = [
|
|
20
|
+
// Localhost — any port. Matches dev (http://localhost:3000), preview
|
|
21
|
+
// builds, custom-port hosting on the user's own machine.
|
|
22
|
+
/^https?:\/\/localhost(?::\d+)?$/,
|
|
23
|
+
/^https?:\/\/127\.0\.0\.1(?::\d+)?$/,
|
|
24
|
+
// Production + subdomains. *.synkit.net catches staging /
|
|
25
|
+
// preview deploys without an explicit allowlist entry per env.
|
|
26
|
+
/^https:\/\/nexus\.synkit\.net$/,
|
|
27
|
+
/^https:\/\/[\w-]+\.synkit\.net$/
|
|
28
|
+
];
|
|
29
|
+
function parseExtraAllowedOrigins(raw) {
|
|
30
|
+
if (!raw) return [];
|
|
31
|
+
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
32
|
+
}
|
|
33
|
+
function isOriginAllowed(origin, extraAllowed) {
|
|
34
|
+
if (extraAllowed.includes(origin)) return true;
|
|
35
|
+
return DEFAULT_ALLOWED_ORIGIN_PATTERNS.some((rx) => rx.test(origin));
|
|
36
|
+
}
|
|
37
|
+
var NexusBridge = class {
|
|
38
|
+
httpServer;
|
|
39
|
+
wss;
|
|
40
|
+
nexus = null;
|
|
41
|
+
pending = /* @__PURE__ */ new Map();
|
|
42
|
+
nextId = 0;
|
|
43
|
+
log;
|
|
44
|
+
// Parsed once at construction. Empty array when env var is unset.
|
|
45
|
+
extraAllowedOrigins;
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
const host = opts.host ?? "127.0.0.1";
|
|
48
|
+
this.log = opts.log ?? ((...a) => console.error("[bridge]", ...a));
|
|
49
|
+
this.extraAllowedOrigins = parseExtraAllowedOrigins(
|
|
50
|
+
process.env.NEXUS_MCP_ALLOWED_ORIGINS
|
|
51
|
+
);
|
|
52
|
+
if (this.extraAllowedOrigins.length > 0) {
|
|
53
|
+
this.log(
|
|
54
|
+
`allowing extra origins: ${this.extraAllowedOrigins.join(", ")}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.httpServer = createServer((req, res) => this.handleHttp(req, res));
|
|
58
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
59
|
+
this.wss.on("error", (err) => this.log("WS server error:", err));
|
|
60
|
+
this.httpServer.on("upgrade", (req, socket, head) => {
|
|
61
|
+
this.wss.handleUpgrade(
|
|
62
|
+
req,
|
|
63
|
+
socket,
|
|
64
|
+
head,
|
|
65
|
+
(ws) => this.handleConnect(ws)
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
this.httpServer.listen(opts.port, host);
|
|
69
|
+
}
|
|
70
|
+
/** Send a tool-call to Nexus and resolve with its result. Rejects
|
|
71
|
+
* if Nexus is not connected, the call times out, or Nexus returns
|
|
72
|
+
* an error message. */
|
|
73
|
+
async callTool(name, params, timeoutMs = 8e3) {
|
|
74
|
+
if (!this.isConnected()) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"Nexus is not connected to the MCP bridge. Open the editor at http://localhost:3000 with NEXT_PUBLIC_NEXUS_MCP=1 set (see mcp/README.md)."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const id = `c${++this.nextId}`;
|
|
80
|
+
const msg = { type: "tool-call", id, name, params };
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const timeout = setTimeout(() => {
|
|
83
|
+
this.pending.delete(id);
|
|
84
|
+
reject(new Error(`Tool '${name}' timed out after ${timeoutMs}ms`));
|
|
85
|
+
}, timeoutMs);
|
|
86
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
87
|
+
this.nexus.send(JSON.stringify(msg));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
isConnected() {
|
|
91
|
+
return this.nexus !== null && this.nexus.readyState === WebSocket.OPEN;
|
|
92
|
+
}
|
|
93
|
+
close() {
|
|
94
|
+
this.nexus?.close();
|
|
95
|
+
this.wss.close();
|
|
96
|
+
this.httpServer.close();
|
|
97
|
+
for (const p of this.pending.values()) {
|
|
98
|
+
clearTimeout(p.timeout);
|
|
99
|
+
p.reject(new Error("Bridge closed"));
|
|
100
|
+
}
|
|
101
|
+
this.pending.clear();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* HTTP handler. Three endpoints:
|
|
105
|
+
* GET /health → connection status (which client is up,
|
|
106
|
+
* how many in-flight pending calls)
|
|
107
|
+
* POST /call → {"name", "params"} → calls the tool
|
|
108
|
+
* via Nexus, returns the result (or 4xx
|
|
109
|
+
* if Nexus isn't connected / tool errs)
|
|
110
|
+
* * → 404
|
|
111
|
+
*
|
|
112
|
+
* Body must be JSON. We tolerate empty params (treated as {}) so
|
|
113
|
+
* `curl -X POST .../call -d '{"name":"ping"}'` works without a
|
|
114
|
+
* params field.
|
|
115
|
+
*
|
|
116
|
+
* CORS + Private Network Access:
|
|
117
|
+
* Every response carries permissive CORS headers + the PNA opt-in
|
|
118
|
+
* header `Access-Control-Allow-Private-Network: true`. Without
|
|
119
|
+
* PNA, Chrome treats HTTPS→localhost requests as cross-origin
|
|
120
|
+
* private-network requests and (depending on policy) blocks them
|
|
121
|
+
* or forces a user-consent prompt every session. With PNA, the
|
|
122
|
+
* prompt happens at most once.
|
|
123
|
+
*
|
|
124
|
+
* We mirror Origin back rather than fix an allowlist — the bridge
|
|
125
|
+
* only ever binds to 127.0.0.1, so the threat surface is "another
|
|
126
|
+
* process on the user's own machine," which could already drive
|
|
127
|
+
* the editor with curl. Permissive Origin doesn't broaden that.
|
|
128
|
+
*/
|
|
129
|
+
handleHttp(req, res) {
|
|
130
|
+
const url = req.url ?? "/";
|
|
131
|
+
const origin = req.headers.origin;
|
|
132
|
+
const originAllowed = typeof origin === "string" && origin.length > 0 && isOriginAllowed(origin, this.extraAllowedOrigins);
|
|
133
|
+
const setCorsHeaders = () => {
|
|
134
|
+
if (originAllowed) {
|
|
135
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
136
|
+
res.setHeader("Vary", "Origin");
|
|
137
|
+
} else if (typeof origin === "string" && origin.length > 0) {
|
|
138
|
+
this.log(`rejecting origin: ${origin}`);
|
|
139
|
+
}
|
|
140
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
141
|
+
res.setHeader(
|
|
142
|
+
"Access-Control-Allow-Headers",
|
|
143
|
+
"content-type, authorization"
|
|
144
|
+
);
|
|
145
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
146
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
147
|
+
};
|
|
148
|
+
if (req.method === "OPTIONS") {
|
|
149
|
+
setCorsHeaders();
|
|
150
|
+
res.statusCode = 204;
|
|
151
|
+
res.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
setCorsHeaders();
|
|
155
|
+
if (req.method === "GET" && url === "/health") {
|
|
156
|
+
res.statusCode = 200;
|
|
157
|
+
res.setHeader("content-type", "application/json");
|
|
158
|
+
res.end(
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
ok: true,
|
|
161
|
+
nexusConnected: this.isConnected(),
|
|
162
|
+
pendingCalls: this.pending.size,
|
|
163
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (req.method === "POST" && url === "/call") {
|
|
169
|
+
let body = "";
|
|
170
|
+
req.setEncoding("utf8");
|
|
171
|
+
req.on("data", (chunk) => {
|
|
172
|
+
body += chunk;
|
|
173
|
+
if (body.length > 1e6) {
|
|
174
|
+
req.destroy();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
req.on("end", async () => {
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = body.length > 0 ? JSON.parse(body) : {};
|
|
181
|
+
} catch {
|
|
182
|
+
res.statusCode = 400;
|
|
183
|
+
res.setHeader("content-type", "application/json");
|
|
184
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
188
|
+
res.statusCode = 400;
|
|
189
|
+
res.setHeader("content-type", "application/json");
|
|
190
|
+
res.end(JSON.stringify({ error: "`name` is required (string)" }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const params = typeof parsed.params === "object" && parsed.params !== null ? parsed.params : {};
|
|
194
|
+
try {
|
|
195
|
+
const result = await this.callTool(parsed.name, params);
|
|
196
|
+
res.statusCode = 200;
|
|
197
|
+
res.setHeader("content-type", "application/json");
|
|
198
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
199
|
+
} catch (e) {
|
|
200
|
+
res.statusCode = 200;
|
|
201
|
+
res.setHeader("content-type", "application/json");
|
|
202
|
+
res.end(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
ok: false,
|
|
205
|
+
error: e instanceof Error ? e.message : String(e)
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
res.statusCode = 404;
|
|
213
|
+
res.setHeader("content-type", "application/json");
|
|
214
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
215
|
+
}
|
|
216
|
+
handleConnect(ws) {
|
|
217
|
+
if (this.nexus && this.nexus.readyState === WebSocket.OPEN) {
|
|
218
|
+
this.log("replacing existing Nexus connection (likely reload)");
|
|
219
|
+
this.nexus.close(1e3, "replaced by new connection");
|
|
220
|
+
}
|
|
221
|
+
this.nexus = ws;
|
|
222
|
+
this.log("Nexus connected");
|
|
223
|
+
ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
|
|
224
|
+
ws.on("close", () => {
|
|
225
|
+
if (this.nexus === ws) {
|
|
226
|
+
this.nexus = null;
|
|
227
|
+
this.log("Nexus disconnected");
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
ws.on("error", (err) => this.log("WS connection error:", err));
|
|
231
|
+
}
|
|
232
|
+
handleMessage(ws, raw) {
|
|
233
|
+
let msg;
|
|
234
|
+
try {
|
|
235
|
+
msg = JSON.parse(raw);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
this.log("ignoring malformed message:", e.message);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (msg.type === "hello") {
|
|
241
|
+
if (msg.protocolVersion !== BRIDGE_PROTOCOL_VERSION) {
|
|
242
|
+
this.log(
|
|
243
|
+
`protocol version mismatch (Nexus=${msg.protocolVersion}, bridge=${BRIDGE_PROTOCOL_VERSION}). Will try to continue.`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
this.log("hello from", msg.client, "capabilities:", msg.capabilities);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (msg.type === "tool-result") {
|
|
250
|
+
const pending = this.pending.get(msg.id);
|
|
251
|
+
if (!pending) {
|
|
252
|
+
this.log("tool-result for unknown id:", msg.id);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
clearTimeout(pending.timeout);
|
|
256
|
+
this.pending.delete(msg.id);
|
|
257
|
+
if (msg.error) pending.reject(new Error(msg.error.message));
|
|
258
|
+
else pending.resolve(msg.result);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.log("unexpected message type from Nexus:", msg.type);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// src/index.ts
|
|
266
|
+
var DEFAULT_PORT = 7811;
|
|
267
|
+
var port = Number(process.env.NEXUS_MCP_PORT ?? DEFAULT_PORT);
|
|
268
|
+
var log = (...args) => console.error("[nexus-mcp]", ...args);
|
|
269
|
+
var bridge = new NexusBridge({ port, log });
|
|
270
|
+
log(`WebSocket bridge listening on 127.0.0.1:${port}`);
|
|
271
|
+
var server = new Server(
|
|
272
|
+
{ name: "nexus-mcp", version: "0.1.0" },
|
|
273
|
+
{ capabilities: { tools: {} } }
|
|
274
|
+
);
|
|
275
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
276
|
+
tools: [
|
|
277
|
+
{
|
|
278
|
+
name: "ping",
|
|
279
|
+
description: "Health check for the Nexus bridge. Returns { ok, layerCount, timestamp }. Use this FIRST to confirm the editor is connected before attempting other tools.",
|
|
280
|
+
inputSchema: {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {},
|
|
283
|
+
additionalProperties: false
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: "get_canvas_state",
|
|
288
|
+
description: "Return the current state of the Nexus canvas \u2014 every layer with its id, name, type, kind, parent, bounds, rotation, visibility, locked flag, and parameters. Read-only. ALWAYS call this before making changes so you know what's already on the canvas and can reference existing layer ids.",
|
|
289
|
+
inputSchema: {
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: {},
|
|
292
|
+
additionalProperties: false
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "list_generator_types",
|
|
297
|
+
description: "List the available generator types you can add to the canvas. Returns the literal strings the editor accepts (e.g. 'node-machine', 'array', 'typeset'). Call this if the user asks for a generator by a casual name and you need to map it to the canonical type.",
|
|
298
|
+
inputSchema: {
|
|
299
|
+
type: "object",
|
|
300
|
+
properties: {},
|
|
301
|
+
additionalProperties: false
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "add_layer",
|
|
306
|
+
description: "Add a new layer to the Nexus canvas. Returns the new layer's id. Use list_generator_types first if you're unsure of the type string \u2014 passing a wrong type errors out instead of silently failing.",
|
|
307
|
+
inputSchema: {
|
|
308
|
+
type: "object",
|
|
309
|
+
properties: {
|
|
310
|
+
type: {
|
|
311
|
+
type: "string",
|
|
312
|
+
description: "Canonical layer type (e.g. 'array', 'typeset', 'node-machine', 'rect', 'ellipse')."
|
|
313
|
+
},
|
|
314
|
+
bounds: {
|
|
315
|
+
type: "object",
|
|
316
|
+
description: "Optional. Layer-local bounds rect on the infinite canvas. Any omitted field uses the store's default.",
|
|
317
|
+
properties: {
|
|
318
|
+
x: { type: "number" },
|
|
319
|
+
y: { type: "number" },
|
|
320
|
+
width: { type: "number" },
|
|
321
|
+
height: { type: "number" }
|
|
322
|
+
},
|
|
323
|
+
additionalProperties: false
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
required: ["type"],
|
|
327
|
+
additionalProperties: false
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "list_array_variant_presets",
|
|
332
|
+
description: "List the built-in shape presets you can inject into an array generator's library via add_array_variants. Returns [{ key, name }] \u2014 pass the keys to add_array_variants's `shapes` parameter.",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {},
|
|
336
|
+
additionalProperties: false
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: "add_array_variants",
|
|
341
|
+
description: "Inject preset shape variants into an array generator's library (the 'palette' of shapes it stamps across cells). Pass `shapes` as a list of preset keys (call list_array_variant_presets to enumerate). Omit `shapes` to add every preset at once \u2014 useful when the user asks for 'lots of options'. If the array has no library yet, one is created in 'random' mode with a default Library Box below the generator. Variants default to white fill.",
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: "object",
|
|
344
|
+
properties: {
|
|
345
|
+
arrayLayerId: { type: "string" },
|
|
346
|
+
shapes: { type: "array", items: { type: "string" } },
|
|
347
|
+
fill: { type: "string" }
|
|
348
|
+
},
|
|
349
|
+
required: ["arrayLayerId"],
|
|
350
|
+
additionalProperties: false
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "set_array_variants",
|
|
355
|
+
description: "REPLACE an array generator's variant library with the supplied preset shapes (NOT append). Use this for curated themes \u2014 e.g. one array with just rounded shapes, another with just polygons. shapes is required. Optional `mode` flips the library mode at the same time: pass 'random' for sources where light-ramp doesn't work (e.g. pure black silhouettes), 'light' for grayscale ramps, 'mosaic' for tile-grid sampling.",
|
|
356
|
+
inputSchema: {
|
|
357
|
+
type: "object",
|
|
358
|
+
properties: {
|
|
359
|
+
arrayLayerId: { type: "string" },
|
|
360
|
+
shapes: {
|
|
361
|
+
type: "array",
|
|
362
|
+
items: { type: "string" },
|
|
363
|
+
minItems: 1
|
|
364
|
+
},
|
|
365
|
+
mode: {
|
|
366
|
+
type: "string",
|
|
367
|
+
enum: ["light", "random", "mosaic"]
|
|
368
|
+
},
|
|
369
|
+
fill: { type: "string" }
|
|
370
|
+
},
|
|
371
|
+
required: ["arrayLayerId", "shapes"],
|
|
372
|
+
additionalProperties: false
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "update_layer_param",
|
|
377
|
+
description: "Update a single parameter on a layer (any of the inspector knobs visible in get_canvas_state.parameters). Common keys on array layers: cellSize, colorMode (preserve | palette | fixed | gradient | sample | luma), overrideColor, paletteRandom, paletteCount, palette0..7, gradientStart, gradientEnd, gradientAngle, rotationJitter, scaleJitter, glow, glowStrength, glowRadius, sourceContrast, sourceGamma, sourceInvert. Value type follows the parameter (number, string, boolean).",
|
|
378
|
+
inputSchema: {
|
|
379
|
+
type: "object",
|
|
380
|
+
properties: {
|
|
381
|
+
id: { type: "string" },
|
|
382
|
+
key: { type: "string" },
|
|
383
|
+
value: {}
|
|
384
|
+
},
|
|
385
|
+
required: ["id", "key"],
|
|
386
|
+
additionalProperties: false
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: "update_layer_params",
|
|
391
|
+
description: "Set MANY params on ONE layer in a single store mutation \u2014 collapses N param edits into 1 React render. Reach for this whenever you'd otherwise fire 2+ update_layer_param calls on the same layer (palette setup, jitter group, glow config). Render cost for an array layer is ~10-30ms per rebuild, so batching compounds fast. `params` is an object map of { key: value }; pass null to clear an optional field.",
|
|
392
|
+
inputSchema: {
|
|
393
|
+
type: "object",
|
|
394
|
+
properties: {
|
|
395
|
+
id: { type: "string" },
|
|
396
|
+
params: {
|
|
397
|
+
type: "object",
|
|
398
|
+
additionalProperties: true,
|
|
399
|
+
description: "Object map of { paramKey: value } pairs to merge."
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
required: ["id", "params"],
|
|
403
|
+
additionalProperties: false
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "bulk_update_layers",
|
|
408
|
+
description: "Apply many ops across many layers in ONE store mutation \u2014 the heavy machinery for compositions that touch multiple cells. Each op carries a layer `id` and may include `params` (merged into parameters) and/or `bounds` (partial x/y/width/height overwrite). All ops land inside a single zustand set() \u2192 one render pass for the entire batch. For a 6-cell grid configured with 8 params each, this is the difference between ~500ms of churn (48 individual calls) and ~30ms (one bulk). Mismatched ids skip silently \u2014 we apply what we can rather than rejecting the whole batch. NOT for interactive drags \u2014 this skips the keyframe-aware code path that set_layer_bounds uses.",
|
|
409
|
+
inputSchema: {
|
|
410
|
+
type: "object",
|
|
411
|
+
properties: {
|
|
412
|
+
ops: {
|
|
413
|
+
type: "array",
|
|
414
|
+
minItems: 1,
|
|
415
|
+
items: {
|
|
416
|
+
type: "object",
|
|
417
|
+
properties: {
|
|
418
|
+
id: { type: "string" },
|
|
419
|
+
params: { type: "object", additionalProperties: true },
|
|
420
|
+
bounds: {
|
|
421
|
+
type: "object",
|
|
422
|
+
properties: {
|
|
423
|
+
x: { type: "number" },
|
|
424
|
+
y: { type: "number" },
|
|
425
|
+
width: { type: "number" },
|
|
426
|
+
height: { type: "number" }
|
|
427
|
+
},
|
|
428
|
+
additionalProperties: false
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
required: ["id"],
|
|
432
|
+
additionalProperties: false
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
required: ["ops"],
|
|
437
|
+
additionalProperties: false
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: "duplicate_layer",
|
|
442
|
+
description: "Duplicate a layer by id. Returns the new layer's id so subsequent set_layer_bounds calls can position the copy. The duplicate lands in the same spot as the original; move it explicitly if you don't want overlap.",
|
|
443
|
+
inputSchema: {
|
|
444
|
+
type: "object",
|
|
445
|
+
properties: { id: { type: "string" } },
|
|
446
|
+
required: ["id"],
|
|
447
|
+
additionalProperties: false
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "set_layer_bounds",
|
|
452
|
+
description: "Move and/or resize a layer. `bounds` is a partial \u2014 any omitted field keeps its current value. To move a layer, pass just { x, y }; to resize, pass just { width, height }.",
|
|
453
|
+
inputSchema: {
|
|
454
|
+
type: "object",
|
|
455
|
+
properties: {
|
|
456
|
+
id: { type: "string" },
|
|
457
|
+
bounds: {
|
|
458
|
+
type: "object",
|
|
459
|
+
properties: {
|
|
460
|
+
x: { type: "number" },
|
|
461
|
+
y: { type: "number" },
|
|
462
|
+
width: { type: "number" },
|
|
463
|
+
height: { type: "number" }
|
|
464
|
+
},
|
|
465
|
+
additionalProperties: false
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
required: ["id", "bounds"],
|
|
469
|
+
additionalProperties: false
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: "remove_layer",
|
|
474
|
+
description: "Remove a layer by id. Idempotent \u2014 returns existed=false if the layer was already gone.",
|
|
475
|
+
inputSchema: {
|
|
476
|
+
type: "object",
|
|
477
|
+
properties: { id: { type: "string" } },
|
|
478
|
+
required: ["id"],
|
|
479
|
+
additionalProperties: false
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
name: "save_canvas_snapshot",
|
|
484
|
+
description: "Save the current canvas (layers + selection + viewport) to localStorage under a named slot. Use BEFORE risky experiments so you can come back. Survives tab reloads. Slot name defaults to 'default'.",
|
|
485
|
+
inputSchema: {
|
|
486
|
+
type: "object",
|
|
487
|
+
properties: { slot: { type: "string" } },
|
|
488
|
+
additionalProperties: false
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: "restore_canvas_snapshot",
|
|
493
|
+
description: "Restore a previously-saved snapshot by slot name. Overwrites the entire layer tree as a single undo checkpoint, so Ctrl+Z reverts the restore. Slot defaults to 'default'.",
|
|
494
|
+
inputSchema: {
|
|
495
|
+
type: "object",
|
|
496
|
+
properties: { slot: { type: "string" } },
|
|
497
|
+
additionalProperties: false
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
name: "list_canvas_snapshots",
|
|
502
|
+
description: "List every named canvas snapshot in localStorage with savedAt timestamp and layer count. Use to enumerate experiments before restoring.",
|
|
503
|
+
inputSchema: {
|
|
504
|
+
type: "object",
|
|
505
|
+
properties: {},
|
|
506
|
+
additionalProperties: false
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: "delete_canvas_snapshot",
|
|
511
|
+
description: "Delete a named snapshot. Idempotent.",
|
|
512
|
+
inputSchema: {
|
|
513
|
+
type: "object",
|
|
514
|
+
properties: { slot: { type: "string" } },
|
|
515
|
+
required: ["slot"],
|
|
516
|
+
additionalProperties: false
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
]
|
|
520
|
+
}));
|
|
521
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
522
|
+
const { name, arguments: args } = req.params;
|
|
523
|
+
try {
|
|
524
|
+
const result = await bridge.callTool(
|
|
525
|
+
name,
|
|
526
|
+
args ?? {}
|
|
527
|
+
);
|
|
528
|
+
return {
|
|
529
|
+
content: [
|
|
530
|
+
{ type: "text", text: JSON.stringify(result, null, 2) }
|
|
531
|
+
]
|
|
532
|
+
};
|
|
533
|
+
} catch (e) {
|
|
534
|
+
return {
|
|
535
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
536
|
+
isError: true
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
var shutdown = () => {
|
|
541
|
+
log("shutting down");
|
|
542
|
+
bridge.close();
|
|
543
|
+
process.exit(0);
|
|
544
|
+
};
|
|
545
|
+
process.on("SIGINT", shutdown);
|
|
546
|
+
process.on("SIGTERM", shutdown);
|
|
547
|
+
var transport = new StdioServerTransport();
|
|
548
|
+
await server.connect(transport);
|
|
549
|
+
log("stdio transport connected \u2014 ready for Claude");
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@synkit/nexus",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP server that lets any AI agent drive the Nexus design tool over a local WebSocket bridge.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"nexus-mcp": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"dev": "tsx src/index.ts",
|
|
21
|
+
"bridge": "tsx src/bridge-only.ts",
|
|
22
|
+
"test-tools": "tsx src/test-tools.ts",
|
|
23
|
+
"tool": "tsx src/cli.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"model-context-protocol",
|
|
33
|
+
"nexus",
|
|
34
|
+
"synkit",
|
|
35
|
+
"agentic",
|
|
36
|
+
"design",
|
|
37
|
+
"procedural",
|
|
38
|
+
"ai",
|
|
39
|
+
"claude"
|
|
40
|
+
],
|
|
41
|
+
"homepage": "https://nexus.synkit.net",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/synkit/nexus.git",
|
|
45
|
+
"directory": "mcp"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/synkit/nexus/issues"
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"author": "Synkit",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
54
|
+
"ws": "^8.18.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^20",
|
|
58
|
+
"@types/ws": "^8.5.13",
|
|
59
|
+
"tsup": "^8.5.1",
|
|
60
|
+
"tsx": "^4.19.2",
|
|
61
|
+
"typescript": "^5"
|
|
62
|
+
}
|
|
63
|
+
}
|