construct-shader-graph-mcp 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/package.json +1 -1
  3. package/src/server.mjs +568 -396
package/README.md CHANGED
@@ -80,6 +80,7 @@ npm start
80
80
  Optional environment variable:
81
81
 
82
82
  - `MCP_BRIDGE_PORT` to change the browser bridge port from `6359`
83
+ - `MCP_CONTROL_PORT` to change the internal local control port used for multi-host sharing
83
84
 
84
85
  Example:
85
86
 
@@ -87,6 +88,19 @@ Example:
87
88
  MCP_BRIDGE_PORT=6360 construct-shader-graph-mcp
88
89
  ```
89
90
 
91
+ ## Multiple MCP hosts
92
+
93
+ This package supports multiple MCP clients on the same machine.
94
+
95
+ - The first process becomes the primary backend and owns the browser bridge port.
96
+ - Later processes detect the running backend and act as lightweight followers.
97
+ - This allows tools like Claude Desktop and LM Studio to share the same live Construct Shader Graph connection.
98
+
99
+ By default:
100
+
101
+ - browser bridge: `6359`
102
+ - local control port: `6360`
103
+
90
104
  ## How it works
91
105
 
92
106
  There are two sides to the integration:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "construct-shader-graph-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Standalone MCP server for Construct Shader Graph",
5
5
  "type": "module",
6
6
  "files": [
package/src/server.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import http from "node:http";
3
+ import net from "node:net";
2
4
  import path from "node:path";
5
+ import readline from "node:readline";
3
6
  import { fileURLToPath } from "node:url";
4
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -10,10 +13,15 @@ const __filename = fileURLToPath(import.meta.url);
10
13
  const __dirname = path.dirname(__filename);
11
14
 
12
15
  const BRIDGE_PORT = Number(process.env.MCP_BRIDGE_PORT || 6359);
16
+ const CONTROL_PORT = Number(process.env.MCP_CONTROL_PORT || BRIDGE_PORT + 1);
13
17
  const SKILL_PATH = path.resolve(__dirname, "guidance/skill.md");
14
18
 
15
19
  const sessions = new Map();
16
20
  let selectedSessionId = null;
21
+ let localServer = null;
22
+ let bridge = null;
23
+ let controlServer = null;
24
+ let isPrimaryInstance = false;
17
25
 
18
26
  function log(message, ...args) {
19
27
  console.error(`[construct-shader-graph-mcp] ${message}`, ...args);
@@ -105,6 +113,10 @@ function ensureSelectedSession() {
105
113
  }
106
114
 
107
115
  function sendJson(socket, payload) {
116
+ socket.write(`${JSON.stringify(payload)}\n`);
117
+ }
118
+
119
+ function sendWsJson(socket, payload) {
108
120
  socket.send(JSON.stringify(payload));
109
121
  }
110
122
 
@@ -132,7 +144,7 @@ function invokeSession(session, method, args = []) {
132
144
  method,
133
145
  });
134
146
 
135
- sendJson(session.socket, {
147
+ sendWsJson(session.socket, {
136
148
  type: "invoke",
137
149
  requestId,
138
150
  method,
@@ -141,427 +153,587 @@ function invokeSession(session, method, args = []) {
141
153
  });
142
154
  }
143
155
 
144
- const bridge = new WebSocketServer({ host: "127.0.0.1", port: BRIDGE_PORT });
156
+ function createToolDefinitions() {
157
+ return [
158
+ {
159
+ name: "get_skill_guidance",
160
+ config: {
161
+ description:
162
+ "Return the full Construct Shader Graph MCP guidance and best practices.",
163
+ inputSchema: {},
164
+ outputSchema: {
165
+ title: z.string(),
166
+ content: z.string(),
167
+ },
168
+ },
169
+ handler: async () => {
170
+ const result = {
171
+ title: "Construct Shader Graph MCP Guidance",
172
+ content: loadSkillText(),
173
+ };
174
+ return {
175
+ content: [{ type: "text", text: result.content }],
176
+ structuredContent: result,
177
+ };
178
+ },
179
+ },
180
+ {
181
+ name: "list_projects",
182
+ config: {
183
+ description:
184
+ "List connected Construct Shader Graph tabs registered with the local bridge.",
185
+ inputSchema: {},
186
+ outputSchema: {
187
+ projects: z.array(
188
+ z.object({
189
+ sessionId: z.string(),
190
+ project: z.object({
191
+ name: z.string(),
192
+ version: z.string().optional(),
193
+ author: z.string().optional(),
194
+ category: z.string().optional(),
195
+ description: z.string().optional(),
196
+ shaderInfo: z.any().optional(),
197
+ }),
198
+ connectedAt: z.string(),
199
+ updatedAt: z.string(),
200
+ manifestVersion: z.string().nullable(),
201
+ methodCount: z.number(),
202
+ selected: z.boolean(),
203
+ }),
204
+ ),
205
+ selectedSessionId: z.string().nullable(),
206
+ },
207
+ },
208
+ handler: async () => {
209
+ const projects = [...sessions.values()].map(getSessionSummary);
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: JSON.stringify({ projects, selectedSessionId }, null, 2),
215
+ },
216
+ ],
217
+ structuredContent: {
218
+ projects,
219
+ selectedSessionId,
220
+ },
221
+ };
222
+ },
223
+ },
224
+ {
225
+ name: "select_project",
226
+ config: {
227
+ description:
228
+ "Choose which connected shader graph tab future MCP calls should target.",
229
+ inputSchema: {
230
+ sessionId: z.string().describe("Session id returned by list_projects."),
231
+ },
232
+ outputSchema: {
233
+ sessionId: z.string(),
234
+ project: z.any(),
235
+ },
236
+ },
237
+ handler: async ({ sessionId }) => {
238
+ const session = ensureSession(sessionId);
239
+ selectedSessionId = sessionId;
240
+ const result = {
241
+ sessionId,
242
+ project: session.project,
243
+ };
244
+ return {
245
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
246
+ structuredContent: result,
247
+ };
248
+ },
249
+ },
250
+ {
251
+ name: "get_project_manifest",
252
+ config: {
253
+ description:
254
+ "Get the machine-readable API manifest for the selected project.",
255
+ inputSchema: {
256
+ sessionId: z
257
+ .string()
258
+ .optional()
259
+ .describe("Optional session id; defaults to the selected project."),
260
+ },
261
+ outputSchema: {
262
+ sessionId: z.string(),
263
+ project: z.any(),
264
+ manifest: z.any(),
265
+ },
266
+ },
267
+ handler: async ({ sessionId }) => {
268
+ const session = sessionId
269
+ ? ensureSession(sessionId)
270
+ : ensureSelectedSession();
271
+ const result = {
272
+ sessionId: session.sessionId,
273
+ project: session.project,
274
+ manifest: session.manifest,
275
+ };
276
+ return {
277
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
278
+ structuredContent: result,
279
+ };
280
+ },
281
+ },
282
+ {
283
+ name: "call_project_method",
284
+ config: {
285
+ description:
286
+ "Call one method from the selected project's shaderGraphAPI and return its exact result.",
287
+ inputSchema: {
288
+ sessionId: z
289
+ .string()
290
+ .optional()
291
+ .describe("Optional session id; defaults to the selected project."),
292
+ method: z
293
+ .string()
294
+ .describe("Manifest method path, for example nodes.create or shader.getInfo."),
295
+ args: z
296
+ .array(z.any())
297
+ .optional()
298
+ .describe("Positional arguments to pass to the API method."),
299
+ },
300
+ outputSchema: {
301
+ sessionId: z.string(),
302
+ project: z.any(),
303
+ method: z.string(),
304
+ args: z.array(z.any()),
305
+ durationMs: z.number(),
306
+ result: z.any(),
307
+ },
308
+ },
309
+ handler: async ({ sessionId, method, args = [] }) => {
310
+ const session = sessionId
311
+ ? ensureSession(sessionId)
312
+ : ensureSelectedSession();
313
+ const response = await invokeSession(session, method, args);
314
+ const result = {
315
+ sessionId: session.sessionId,
316
+ project: session.project,
317
+ method,
318
+ args,
319
+ durationMs: response.durationMs ?? 0,
320
+ result: response.result,
321
+ };
322
+ return {
323
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
324
+ structuredContent: result,
325
+ };
326
+ },
327
+ },
328
+ ];
329
+ }
145
330
 
146
- bridge.on("connection", (socket) => {
147
- let activeSessionId = null;
331
+ function registerResources(server) {
332
+ server.registerResource(
333
+ "skill-guidance",
334
+ "construct-shader-graph://guidance/skill",
335
+ {
336
+ title: "Construct Shader Graph MCP Guidance",
337
+ description:
338
+ "Full best-practices guidance for using Construct Shader Graph through MCP.",
339
+ mimeType: "text/markdown",
340
+ },
341
+ async (uri) => ({
342
+ contents: [
343
+ {
344
+ uri: uri.href,
345
+ mimeType: "text/markdown",
346
+ text: loadSkillText(),
347
+ },
348
+ ],
349
+ }),
350
+ );
351
+
352
+ server.registerResource(
353
+ "quickstart-guidance",
354
+ "construct-shader-graph://guidance/quickstart",
355
+ {
356
+ title: "Construct Shader Graph MCP Quickstart",
357
+ description: "Short workflow guidance for reliable MCP use.",
358
+ mimeType: "text/markdown",
359
+ },
360
+ async (uri) => ({
361
+ contents: [
362
+ {
363
+ uri: uri.href,
364
+ mimeType: "text/markdown",
365
+ text: loadQuickstartText(),
366
+ },
367
+ ],
368
+ }),
369
+ );
370
+ }
148
371
 
149
- socket.on("message", (raw) => {
150
- let message;
151
- try {
152
- message = JSON.parse(String(raw));
153
- } catch {
154
- return;
155
- }
372
+ function registerPrompts(server) {
373
+ server.registerPrompt(
374
+ "work-with-shader-graph",
375
+ {
376
+ title: "Work With Shader Graph",
377
+ description:
378
+ "General prompt for safely inspecting and editing a Construct Shader Graph project.",
379
+ argsSchema: z.object({
380
+ task: z.string().optional().describe("The user task to accomplish."),
381
+ }),
382
+ },
383
+ ({ task }) => ({
384
+ messages: [
385
+ {
386
+ role: "user",
387
+ content: {
388
+ type: "text",
389
+ text: `${getPromptPreamble()}\n\nFollow the full guidance resource if more detail is needed.\n\nTask: ${task || "Inspect the current project, understand its graph state, and proceed safely."}`,
390
+ },
391
+ },
392
+ ],
393
+ }),
394
+ );
395
+
396
+ server.registerPrompt(
397
+ "inspect-graph",
398
+ {
399
+ title: "Inspect Graph",
400
+ description: "Prompt for safely inspecting the current graph before any edits.",
401
+ argsSchema: z.object({
402
+ focus: z
403
+ .string()
404
+ .optional()
405
+ .describe("Optional area to inspect, like uniforms, preview, or node types."),
406
+ }),
407
+ },
408
+ ({ focus }) => ({
409
+ messages: [
410
+ {
411
+ role: "user",
412
+ content: {
413
+ type: "text",
414
+ text: `${getPromptPreamble()}\n\nInspect the current graph without mutating it. Read nodes, wires, uniforms, shader info, and any relevant settings first. ${focus ? `Focus on: ${focus}.` : ""}`,
415
+ },
416
+ },
417
+ ],
418
+ }),
419
+ );
420
+
421
+ server.registerPrompt(
422
+ "edit-graph-safely",
423
+ {
424
+ title: "Edit Graph Safely",
425
+ description: "Prompt for making a small validated graph edit with MCP.",
426
+ argsSchema: z.object({
427
+ task: z.string().describe("The graph edit to perform."),
428
+ }),
429
+ },
430
+ ({ task }) => ({
431
+ messages: [
432
+ {
433
+ role: "user",
434
+ content: {
435
+ type: "text",
436
+ text: `${getPromptPreamble()}\n\nMake the smallest valid change that satisfies this task: ${task}\n\nBefore wiring, inspect ports. Before choosing a node type, use nodeTypes.search or nodeTypes.list. After each structural edit, re-read affected nodes or ports and validate preview/code if relevant.`,
437
+ },
438
+ },
439
+ ],
440
+ }),
441
+ );
442
+
443
+ server.registerPrompt(
444
+ "debug-preview-errors",
445
+ {
446
+ title: "Debug Preview Errors",
447
+ description:
448
+ "Prompt for debugging generated code or preview issues in a shader graph project.",
449
+ argsSchema: z.object({
450
+ issue: z.string().optional().describe("Optional description of the observed preview issue."),
451
+ }),
452
+ },
453
+ ({ issue }) => ({
454
+ messages: [
455
+ {
456
+ role: "user",
457
+ content: {
458
+ type: "text",
459
+ text: `${getPromptPreamble()}\n\nDebug the current shader graph by inspecting shader.getGeneratedCode, preview.getErrors, preview settings, node preview, and ai.runDebugCheck. ${issue ? `Observed issue: ${issue}` : ""}`,
460
+ },
461
+ },
462
+ ],
463
+ }),
464
+ );
465
+ }
156
466
 
157
- if (!message || typeof message !== "object") {
158
- return;
159
- }
467
+ function createLocalServer() {
468
+ const server = new McpServer({
469
+ name: "construct-shader-graph",
470
+ version: "0.1.0",
471
+ });
472
+
473
+ registerResources(server);
474
+ registerPrompts(server);
475
+ createToolDefinitions().forEach((tool) => {
476
+ server.registerTool(tool.name, tool.config, tool.handler);
477
+ });
478
+
479
+ return server;
480
+ }
160
481
 
161
- if (message.type === "register") {
162
- const sessionId = String(message.sessionId || "").trim();
163
- if (!sessionId) {
164
- sendJson(socket, { type: "error", message: "Missing sessionId" });
482
+ async function startPrimaryBackend() {
483
+ isPrimaryInstance = true;
484
+ localServer = createLocalServer();
485
+
486
+ bridge = new WebSocketServer({ noServer: true });
487
+ const httpServer = http.createServer();
488
+
489
+ httpServer.on("upgrade", (request, socket, head) => {
490
+ bridge.handleUpgrade(request, socket, head, (ws) => {
491
+ bridge.emit("connection", ws, request);
492
+ });
493
+ });
494
+
495
+ await new Promise((resolve, reject) => {
496
+ httpServer.once("error", reject);
497
+ httpServer.listen(BRIDGE_PORT, "127.0.0.1", () => {
498
+ httpServer.off("error", reject);
499
+ resolve();
500
+ });
501
+ });
502
+
503
+ bridge.on("connection", (socket) => {
504
+ let activeSessionId = null;
505
+
506
+ socket.on("message", (raw) => {
507
+ let message;
508
+ try {
509
+ message = JSON.parse(String(raw));
510
+ } catch {
165
511
  return;
166
512
  }
167
513
 
168
- const session = {
169
- sessionId,
170
- socket,
171
- project: message.project || {
172
- name: "Untitled Shader",
173
- version: "0.0.0.0",
174
- },
175
- manifest: message.manifest || null,
176
- connectedAt: nowIso(),
177
- updatedAt: nowIso(),
178
- pending: new Map(),
179
- };
180
-
181
- sessions.set(sessionId, session);
182
- activeSessionId = sessionId;
183
- if (!selectedSessionId) {
184
- selectedSessionId = sessionId;
514
+ if (!message || typeof message !== "object") {
515
+ return;
185
516
  }
186
517
 
187
- log(`registered ${sessionId} (${session.project?.name || "Untitled Shader"})`);
188
- sendJson(socket, {
189
- type: "registered",
190
- sessionId,
191
- selected: selectedSessionId === sessionId,
192
- });
193
- return;
194
- }
518
+ if (message.type === "register") {
519
+ const sessionId = String(message.sessionId || "").trim();
520
+ if (!sessionId) {
521
+ sendWsJson(socket, { type: "error", message: "Missing sessionId" });
522
+ return;
523
+ }
524
+
525
+ const session = {
526
+ sessionId,
527
+ socket,
528
+ project: message.project || {
529
+ name: "Untitled Shader",
530
+ version: "0.0.0.0",
531
+ },
532
+ manifest: message.manifest || null,
533
+ connectedAt: nowIso(),
534
+ updatedAt: nowIso(),
535
+ pending: new Map(),
536
+ };
537
+
538
+ sessions.set(sessionId, session);
539
+ activeSessionId = sessionId;
540
+ if (!selectedSessionId) {
541
+ selectedSessionId = sessionId;
542
+ }
543
+
544
+ log(`registered ${sessionId} (${session.project?.name || "Untitled Shader"})`);
545
+ sendWsJson(socket, {
546
+ type: "registered",
547
+ sessionId,
548
+ selected: selectedSessionId === sessionId,
549
+ });
550
+ return;
551
+ }
195
552
 
196
- if (!activeSessionId) {
197
- return;
198
- }
553
+ if (!activeSessionId) {
554
+ return;
555
+ }
199
556
 
200
- const session = sessions.get(activeSessionId);
201
- if (!session) {
202
- return;
203
- }
557
+ const session = sessions.get(activeSessionId);
558
+ if (!session) {
559
+ return;
560
+ }
204
561
 
205
- session.updatedAt = nowIso();
562
+ session.updatedAt = nowIso();
206
563
 
207
- if (message.type === "project-updated") {
208
- session.project = message.project || session.project;
209
- session.manifest = message.manifest || session.manifest;
210
- return;
211
- }
564
+ if (message.type === "project-updated") {
565
+ session.project = message.project || session.project;
566
+ session.manifest = message.manifest || session.manifest;
567
+ return;
568
+ }
569
+
570
+ if (message.type === "result") {
571
+ const pending = session.pending.get(message.requestId);
572
+ if (!pending) {
573
+ return;
574
+ }
575
+
576
+ clearTimeout(pending.timeoutId);
577
+ session.pending.delete(message.requestId);
578
+
579
+ if (message.ok) {
580
+ pending.resolve(message);
581
+ } else {
582
+ const error = new Error(
583
+ message.error?.message || `Call '${pending.method}' failed`,
584
+ );
585
+ error.stack = message.error?.stack || error.stack;
586
+ pending.reject(error);
587
+ }
588
+ }
589
+ });
212
590
 
213
- if (message.type === "result") {
214
- const pending = session.pending.get(message.requestId);
215
- if (!pending) {
591
+ socket.on("close", () => {
592
+ if (!activeSessionId) {
216
593
  return;
217
594
  }
218
595
 
219
- clearTimeout(pending.timeoutId);
220
- session.pending.delete(message.requestId);
596
+ const session = sessions.get(activeSessionId);
597
+ if (!session) {
598
+ return;
599
+ }
221
600
 
222
- if (message.ok) {
223
- pending.resolve(message);
224
- } else {
225
- const error = new Error(
226
- message.error?.message || `Call '${pending.method}' failed`,
227
- );
228
- error.stack = message.error?.stack || error.stack;
229
- pending.reject(error);
601
+ for (const pending of session.pending.values()) {
602
+ clearTimeout(pending.timeoutId);
603
+ pending.reject(new Error(`Session '${activeSessionId}' disconnected`));
230
604
  }
231
- }
605
+
606
+ sessions.delete(activeSessionId);
607
+ if (selectedSessionId === activeSessionId) {
608
+ selectedSessionId = sessions.keys().next().value || null;
609
+ }
610
+
611
+ log(`disconnected ${activeSessionId}`);
612
+ });
232
613
  });
233
614
 
234
- socket.on("close", () => {
235
- if (!activeSessionId) {
236
- return;
237
- }
615
+ log(`bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
238
616
 
239
- const session = sessions.get(activeSessionId);
240
- if (!session) {
241
- return;
242
- }
617
+ controlServer = net.createServer((socket) => {
618
+ const rl = readline.createInterface({ input: socket });
243
619
 
244
- for (const pending of session.pending.values()) {
245
- clearTimeout(pending.timeoutId);
246
- pending.reject(new Error(`Session '${activeSessionId}' disconnected`));
247
- }
620
+ rl.on("line", async (line) => {
621
+ let request;
622
+ try {
623
+ request = JSON.parse(line);
624
+ } catch {
625
+ sendJson(socket, { ok: false, error: "Invalid JSON request" });
626
+ return;
627
+ }
248
628
 
249
- sessions.delete(activeSessionId);
250
- if (selectedSessionId === activeSessionId) {
251
- selectedSessionId = sessions.keys().next().value || null;
252
- }
629
+ if (!request || request.type !== "rpc") {
630
+ sendJson(socket, { ok: false, error: "Invalid control request" });
631
+ return;
632
+ }
633
+
634
+ const tool = createToolDefinitions().find((entry) => entry.name === request.tool);
635
+ if (!tool) {
636
+ sendJson(socket, { id: request.id, ok: false, error: `Unknown tool '${request.tool}'` });
637
+ return;
638
+ }
253
639
 
254
- log(`disconnected ${activeSessionId}`);
640
+ try {
641
+ const response = await tool.handler(request.input || {});
642
+ sendJson(socket, { id: request.id, ok: true, response });
643
+ } catch (error) {
644
+ sendJson(socket, {
645
+ id: request.id,
646
+ ok: false,
647
+ error: error instanceof Error ? error.message : String(error),
648
+ });
649
+ }
650
+ });
255
651
  });
256
- });
257
-
258
- log(`bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
259
-
260
- const server = new McpServer({
261
- name: "construct-shader-graph",
262
- version: "0.1.0",
263
- });
264
-
265
- server.registerResource(
266
- "skill-guidance",
267
- "construct-shader-graph://guidance/skill",
268
- {
269
- title: "Construct Shader Graph MCP Guidance",
270
- description: "Full best-practices guidance for using Construct Shader Graph through MCP.",
271
- mimeType: "text/markdown",
272
- },
273
- async (uri) => ({
274
- contents: [
275
- {
276
- uri: uri.href,
277
- mimeType: "text/markdown",
278
- text: loadSkillText(),
279
- },
280
- ],
281
- }),
282
- );
283
-
284
- server.registerResource(
285
- "quickstart-guidance",
286
- "construct-shader-graph://guidance/quickstart",
287
- {
288
- title: "Construct Shader Graph MCP Quickstart",
289
- description: "Short workflow guidance for reliable MCP use.",
290
- mimeType: "text/markdown",
291
- },
292
- async (uri) => ({
293
- contents: [
294
- {
295
- uri: uri.href,
296
- mimeType: "text/markdown",
297
- text: loadQuickstartText(),
298
- },
299
- ],
300
- }),
301
- );
302
-
303
- server.registerPrompt(
304
- "work-with-shader-graph",
305
- {
306
- title: "Work With Shader Graph",
307
- description: "General prompt for safely inspecting and editing a Construct Shader Graph project.",
308
- argsSchema: z.object({
309
- task: z.string().optional().describe("The user task to accomplish."),
310
- }),
311
- },
312
- ({ task }) => ({
313
- messages: [
314
- {
315
- role: "user",
316
- content: {
317
- type: "text",
318
- text: `${getPromptPreamble()}\n\nFollow the full guidance resource if more detail is needed.\n\nTask: ${task || "Inspect the current project, understand its graph state, and proceed safely."}`,
319
- },
320
- },
321
- ],
322
- }),
323
- );
324
-
325
- server.registerPrompt(
326
- "inspect-graph",
327
- {
328
- title: "Inspect Graph",
329
- description: "Prompt for safely inspecting the current graph before any edits.",
330
- argsSchema: z.object({
331
- focus: z.string().optional().describe("Optional area to inspect, like uniforms, preview, or node types."),
332
- }),
333
- },
334
- ({ focus }) => ({
335
- messages: [
336
- {
337
- role: "user",
338
- content: {
339
- type: "text",
340
- text: `${getPromptPreamble()}\n\nInspect the current graph without mutating it. Read nodes, wires, uniforms, shader info, and any relevant settings first. ${focus ? `Focus on: ${focus}.` : ""}`,
341
- },
342
- },
343
- ],
344
- }),
345
- );
346
-
347
- server.registerPrompt(
348
- "edit-graph-safely",
349
- {
350
- title: "Edit Graph Safely",
351
- description: "Prompt for making a small validated graph edit with MCP.",
352
- argsSchema: z.object({
353
- task: z.string().describe("The graph edit to perform."),
354
- }),
355
- },
356
- ({ task }) => ({
357
- messages: [
358
- {
359
- role: "user",
360
- content: {
361
- type: "text",
362
- text: `${getPromptPreamble()}\n\nMake the smallest valid change that satisfies this task: ${task}\n\nBefore wiring, inspect ports. Before choosing a node type, use nodeTypes.search or nodeTypes.list. After each structural edit, re-read affected nodes or ports and validate preview/code if relevant.`,
363
- },
364
- },
365
- ],
366
- }),
367
- );
368
-
369
- server.registerPrompt(
370
- "debug-preview-errors",
371
- {
372
- title: "Debug Preview Errors",
373
- description: "Prompt for debugging generated code or preview issues in a shader graph project.",
374
- argsSchema: z.object({
375
- issue: z.string().optional().describe("Optional description of the observed preview issue."),
376
- }),
377
- },
378
- ({ issue }) => ({
379
- messages: [
380
- {
381
- role: "user",
382
- content: {
383
- type: "text",
384
- text: `${getPromptPreamble()}\n\nDebug the current shader graph by inspecting shader.getGeneratedCode, preview.getErrors, preview settings, node preview, and ai.runDebugCheck. ${issue ? `Observed issue: ${issue}` : ""}`,
385
- },
386
- },
387
- ],
388
- }),
389
- );
390
-
391
- server.registerTool(
392
- "get_skill_guidance",
393
- {
394
- description:
395
- "Return the full Construct Shader Graph MCP guidance and best practices.",
396
- inputSchema: {},
397
- outputSchema: {
398
- title: z.string(),
399
- content: z.string(),
400
- },
401
- },
402
- async () => {
403
- const result = {
404
- title: "Construct Shader Graph MCP Skill",
405
- content: loadSkillText(),
406
- };
407
- return {
408
- content: [{ type: "text", text: result.content }],
409
- structuredContent: result,
410
- };
411
- },
412
- );
413
-
414
- server.registerTool(
415
- "list_projects",
416
- {
417
- description:
418
- "List connected Construct Shader Graph tabs registered with the local bridge.",
419
- inputSchema: {},
420
- outputSchema: {
421
- projects: z.array(
422
- z.object({
423
- sessionId: z.string(),
424
- project: z.object({
425
- name: z.string(),
426
- version: z.string().optional(),
427
- author: z.string().optional(),
428
- category: z.string().optional(),
429
- description: z.string().optional(),
430
- shaderInfo: z.any().optional(),
431
- }),
432
- connectedAt: z.string(),
433
- updatedAt: z.string(),
434
- manifestVersion: z.string().nullable(),
435
- methodCount: z.number(),
436
- selected: z.boolean(),
437
- }),
438
- ),
439
- selectedSessionId: z.string().nullable(),
440
- },
441
- },
442
- async () => {
443
- const projects = [...sessions.values()].map(getSessionSummary);
444
- return {
445
- content: [
446
- {
447
- type: "text",
448
- text: JSON.stringify({ projects, selectedSessionId }, null, 2),
449
- },
450
- ],
451
- structuredContent: {
452
- projects,
453
- selectedSessionId,
454
- },
455
- };
456
- },
457
- );
458
-
459
- server.registerTool(
460
- "select_project",
461
- {
462
- description:
463
- "Choose which connected shader graph tab future MCP calls should target.",
464
- inputSchema: {
465
- sessionId: z.string().describe("Session id returned by list_projects."),
466
- },
467
- outputSchema: {
468
- sessionId: z.string(),
469
- project: z.any(),
470
- },
471
- },
472
- async ({ sessionId }) => {
473
- const session = ensureSession(sessionId);
474
- selectedSessionId = sessionId;
475
- const result = {
476
- sessionId,
477
- project: session.project,
478
- };
479
- return {
480
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
481
- structuredContent: result,
482
- };
483
- },
484
- );
485
-
486
- server.registerTool(
487
- "get_project_manifest",
488
- {
489
- description:
490
- "Get the machine-readable API manifest for the selected project.",
491
- inputSchema: {
492
- sessionId: z
493
- .string()
494
- .optional()
495
- .describe("Optional session id; defaults to the selected project."),
496
- },
497
- outputSchema: {
498
- sessionId: z.string(),
499
- project: z.any(),
500
- manifest: z.any(),
501
- },
502
- },
503
- async ({ sessionId }) => {
504
- const session = sessionId
505
- ? ensureSession(sessionId)
506
- : ensureSelectedSession();
507
- const result = {
508
- sessionId: session.sessionId,
509
- project: session.project,
510
- manifest: session.manifest,
511
- };
512
- return {
513
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
514
- structuredContent: result,
515
- };
516
- },
517
- );
518
-
519
- server.registerTool(
520
- "call_project_method",
521
- {
522
- description:
523
- "Call one method from the selected project's shaderGraphAPI and return its exact result.",
524
- inputSchema: {
525
- sessionId: z
526
- .string()
527
- .optional()
528
- .describe("Optional session id; defaults to the selected project."),
529
- method: z
530
- .string()
531
- .describe("Manifest method path, for example nodes.create or shader.getInfo."),
532
- args: z
533
- .array(z.any())
534
- .optional()
535
- .describe("Positional arguments to pass to the API method."),
536
- },
537
- outputSchema: {
538
- sessionId: z.string(),
539
- project: z.any(),
540
- method: z.string(),
541
- args: z.array(z.any()),
542
- durationMs: z.number(),
543
- result: z.any(),
544
- },
545
- },
546
- async ({ sessionId, method, args = [] }) => {
547
- const session = sessionId
548
- ? ensureSession(sessionId)
549
- : ensureSelectedSession();
550
- const response = await invokeSession(session, method, args);
551
- const result = {
552
- sessionId: session.sessionId,
553
- project: session.project,
554
- method,
555
- args,
556
- durationMs: response.durationMs ?? 0,
557
- result: response.result,
558
- };
559
- return {
560
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
561
- structuredContent: result,
562
- };
563
- },
564
- );
565
652
 
653
+ await new Promise((resolve, reject) => {
654
+ controlServer.once("error", reject);
655
+ controlServer.listen(CONTROL_PORT, "127.0.0.1", () => {
656
+ controlServer.off("error", reject);
657
+ resolve();
658
+ });
659
+ });
660
+
661
+ log(`control listening on tcp://127.0.0.1:${CONTROL_PORT}`);
662
+ }
663
+
664
+ function createProxyServer() {
665
+ const server = new McpServer({
666
+ name: "construct-shader-graph",
667
+ version: "0.1.0",
668
+ });
669
+
670
+ registerResources(server);
671
+ registerPrompts(server);
672
+ createToolDefinitions().forEach((tool) => {
673
+ server.registerTool(tool.name, tool.config, async (input = {}) => {
674
+ return callPrimaryTool(tool.name, input);
675
+ });
676
+ });
677
+
678
+ return server;
679
+ }
680
+
681
+ function callPrimaryTool(tool, input) {
682
+ return new Promise((resolve, reject) => {
683
+ const socket = net.createConnection({ host: "127.0.0.1", port: CONTROL_PORT });
684
+ const requestId = `rpc_${Date.now()}_${Math.random().toString(16).slice(2)}`;
685
+ const rl = readline.createInterface({ input: socket });
686
+
687
+ socket.on("error", (error) => {
688
+ reject(error);
689
+ });
690
+
691
+ rl.on("line", (line) => {
692
+ let response;
693
+ try {
694
+ response = JSON.parse(line);
695
+ } catch {
696
+ reject(new Error("Invalid control response"));
697
+ socket.destroy();
698
+ return;
699
+ }
700
+
701
+ if (response.id !== requestId) {
702
+ return;
703
+ }
704
+
705
+ rl.close();
706
+ socket.end();
707
+ if (response.ok) {
708
+ resolve(response.response);
709
+ } else {
710
+ reject(new Error(response.error || "Primary MCP request failed"));
711
+ }
712
+ });
713
+
714
+ sendJson(socket, {
715
+ type: "rpc",
716
+ id: requestId,
717
+ tool,
718
+ input,
719
+ });
720
+ });
721
+ }
722
+
723
+ async function ensureBackend() {
724
+ try {
725
+ await startPrimaryBackend();
726
+ } catch (error) {
727
+ if (error?.code !== "EADDRINUSE") {
728
+ throw error;
729
+ }
730
+
731
+ log(`bridge already running on ${BRIDGE_PORT}; starting follower proxy`);
732
+ }
733
+ }
734
+
735
+ await ensureBackend();
736
+
737
+ const server = isPrimaryInstance ? localServer : createProxyServer();
566
738
  const transport = new StdioServerTransport();
567
739
  await server.connect(transport);