construct-shader-graph-mcp 0.1.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.
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);
@@ -27,6 +35,53 @@ function loadSkillText() {
27
35
  return fs.readFileSync(SKILL_PATH, "utf8");
28
36
  }
29
37
 
38
+ function loadQuickstartText() {
39
+ return `# Construct Shader Graph MCP Quickstart
40
+
41
+ Use MCP tools only.
42
+
43
+ ## Core loop
44
+
45
+ 1. Call list_projects.
46
+ 2. Select the correct project with select_project.
47
+ 3. Read get_project_manifest if methods or arguments are unclear.
48
+ 4. Start the task with session.initAIWork.
49
+ 5. Inspect before mutating.
50
+ 6. Make one small edit at a time.
51
+ 7. Re-read affected nodes, ports, wires, or settings.
52
+ 8. Validate with shader.getGeneratedCode, preview.getErrors, and screenshots when needed.
53
+ 9. Finish with session.endAIWork.
54
+
55
+ ## Best practices
56
+
57
+ - Use shader.getInfo metadata to identify the right project.
58
+ - Use exact ids returned by the API.
59
+ - Inspect ports before wiring.
60
+ - Prefer editable input values before adding literal nodes.
61
+ - Use nodeTypes.search or nodeTypes.list before guessing type keys.
62
+ - Use variables when one output fans out to multiple distant places.
63
+
64
+ ## Important method patterns
65
+
66
+ - Discover node types: nodeTypes.search, nodeTypes.list, nodeTypes.get
67
+ - Inspect graph: nodes.list, nodes.get, nodes.getPorts, wires.getAll, uniforms.list
68
+ - Edit node input values: nodes.edit(nodeId, { inputValues: { PortName: value } })
69
+ - Wire nodes: wires.create({ from, to }) after inspecting both ports
70
+ - Validate: ai.runDebugCheck({ includeScreenshot: true })
71
+ `;
72
+ }
73
+
74
+ function getPromptPreamble() {
75
+ return [
76
+ "Use Construct Shader Graph through MCP only.",
77
+ "Start with list_projects and select_project.",
78
+ "Use shader.getInfo metadata to identify the right project.",
79
+ "Use get_project_manifest when capabilities or argument shapes are unclear.",
80
+ "Use exact return values from call_project_method instead of guessing state.",
81
+ "Inspect first, mutate second, and verify after each meaningful edit.",
82
+ ].join("\n");
83
+ }
84
+
30
85
  function getSessionSummary(session) {
31
86
  return {
32
87
  sessionId: session.sessionId,
@@ -58,6 +113,10 @@ function ensureSelectedSession() {
58
113
  }
59
114
 
60
115
  function sendJson(socket, payload) {
116
+ socket.write(`${JSON.stringify(payload)}\n`);
117
+ }
118
+
119
+ function sendWsJson(socket, payload) {
61
120
  socket.send(JSON.stringify(payload));
62
121
  }
63
122
 
@@ -85,7 +144,7 @@ function invokeSession(session, method, args = []) {
85
144
  method,
86
145
  });
87
146
 
88
- sendJson(session.socket, {
147
+ sendWsJson(session.socket, {
89
148
  type: "invoke",
90
149
  requestId,
91
150
  method,
@@ -94,301 +153,587 @@ function invokeSession(session, method, args = []) {
94
153
  });
95
154
  }
96
155
 
97
- 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
+ }
98
330
 
99
- bridge.on("connection", (socket) => {
100
- 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
+ }
101
371
 
102
- socket.on("message", (raw) => {
103
- let message;
104
- try {
105
- message = JSON.parse(String(raw));
106
- } catch {
107
- return;
108
- }
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
+ }
109
466
 
110
- if (!message || typeof message !== "object") {
111
- return;
112
- }
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
+ }
481
+
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;
113
505
 
114
- if (message.type === "register") {
115
- const sessionId = String(message.sessionId || "").trim();
116
- if (!sessionId) {
117
- sendJson(socket, { type: "error", message: "Missing sessionId" });
506
+ socket.on("message", (raw) => {
507
+ let message;
508
+ try {
509
+ message = JSON.parse(String(raw));
510
+ } catch {
118
511
  return;
119
512
  }
120
513
 
121
- const session = {
122
- sessionId,
123
- socket,
124
- project: message.project || {
125
- name: "Untitled Shader",
126
- version: "0.0.0.0",
127
- },
128
- manifest: message.manifest || null,
129
- connectedAt: nowIso(),
130
- updatedAt: nowIso(),
131
- pending: new Map(),
132
- };
133
-
134
- sessions.set(sessionId, session);
135
- activeSessionId = sessionId;
136
- if (!selectedSessionId) {
137
- selectedSessionId = sessionId;
514
+ if (!message || typeof message !== "object") {
515
+ return;
138
516
  }
139
517
 
140
- log(`registered ${sessionId} (${session.project?.name || "Untitled Shader"})`);
141
- sendJson(socket, {
142
- type: "registered",
143
- sessionId,
144
- selected: selectedSessionId === sessionId,
145
- });
146
- return;
147
- }
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
+ }
148
552
 
149
- if (!activeSessionId) {
150
- return;
151
- }
553
+ if (!activeSessionId) {
554
+ return;
555
+ }
152
556
 
153
- const session = sessions.get(activeSessionId);
154
- if (!session) {
155
- return;
156
- }
557
+ const session = sessions.get(activeSessionId);
558
+ if (!session) {
559
+ return;
560
+ }
157
561
 
158
- session.updatedAt = nowIso();
562
+ session.updatedAt = nowIso();
159
563
 
160
- if (message.type === "project-updated") {
161
- session.project = message.project || session.project;
162
- session.manifest = message.manifest || session.manifest;
163
- return;
164
- }
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
+ });
165
590
 
166
- if (message.type === "result") {
167
- const pending = session.pending.get(message.requestId);
168
- if (!pending) {
591
+ socket.on("close", () => {
592
+ if (!activeSessionId) {
169
593
  return;
170
594
  }
171
595
 
172
- clearTimeout(pending.timeoutId);
173
- session.pending.delete(message.requestId);
596
+ const session = sessions.get(activeSessionId);
597
+ if (!session) {
598
+ return;
599
+ }
174
600
 
175
- if (message.ok) {
176
- pending.resolve(message);
177
- } else {
178
- const error = new Error(
179
- message.error?.message || `Call '${pending.method}' failed`,
180
- );
181
- error.stack = message.error?.stack || error.stack;
182
- pending.reject(error);
601
+ for (const pending of session.pending.values()) {
602
+ clearTimeout(pending.timeoutId);
603
+ pending.reject(new Error(`Session '${activeSessionId}' disconnected`));
183
604
  }
184
- }
605
+
606
+ sessions.delete(activeSessionId);
607
+ if (selectedSessionId === activeSessionId) {
608
+ selectedSessionId = sessions.keys().next().value || null;
609
+ }
610
+
611
+ log(`disconnected ${activeSessionId}`);
612
+ });
185
613
  });
186
614
 
187
- socket.on("close", () => {
188
- if (!activeSessionId) {
189
- return;
190
- }
615
+ log(`bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
191
616
 
192
- const session = sessions.get(activeSessionId);
193
- if (!session) {
194
- return;
195
- }
617
+ controlServer = net.createServer((socket) => {
618
+ const rl = readline.createInterface({ input: socket });
196
619
 
197
- for (const pending of session.pending.values()) {
198
- clearTimeout(pending.timeoutId);
199
- pending.reject(new Error(`Session '${activeSessionId}' disconnected`));
200
- }
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
+ }
201
628
 
202
- sessions.delete(activeSessionId);
203
- if (selectedSessionId === activeSessionId) {
204
- selectedSessionId = sessions.keys().next().value || null;
205
- }
629
+ if (!request || request.type !== "rpc") {
630
+ sendJson(socket, { ok: false, error: "Invalid control request" });
631
+ return;
632
+ }
206
633
 
207
- log(`disconnected ${activeSessionId}`);
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
+ }
639
+
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
+ });
208
651
  });
209
- });
210
-
211
- log(`bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
212
-
213
- const server = new McpServer({
214
- name: "construct-shader-graph",
215
- version: "0.1.0",
216
- });
217
-
218
- server.registerTool(
219
- "get_skill_guidance",
220
- {
221
- description:
222
- "Return the full Construct Shader Graph MCP guidance and best practices.",
223
- inputSchema: {},
224
- outputSchema: {
225
- title: z.string(),
226
- content: z.string(),
227
- },
228
- },
229
- async () => {
230
- const result = {
231
- title: "Construct Shader Graph MCP Skill",
232
- content: loadSkillText(),
233
- };
234
- return {
235
- content: [{ type: "text", text: result.content }],
236
- structuredContent: result,
237
- };
238
- },
239
- );
240
-
241
- server.registerTool(
242
- "list_projects",
243
- {
244
- description:
245
- "List connected Construct Shader Graph tabs registered with the local bridge.",
246
- inputSchema: {},
247
- outputSchema: {
248
- projects: z.array(
249
- z.object({
250
- sessionId: z.string(),
251
- project: z.object({
252
- name: z.string(),
253
- version: z.string().optional(),
254
- author: z.string().optional(),
255
- category: z.string().optional(),
256
- description: z.string().optional(),
257
- shaderInfo: z.any().optional(),
258
- }),
259
- connectedAt: z.string(),
260
- updatedAt: z.string(),
261
- manifestVersion: z.string().nullable(),
262
- methodCount: z.number(),
263
- selected: z.boolean(),
264
- }),
265
- ),
266
- selectedSessionId: z.string().nullable(),
267
- },
268
- },
269
- async () => {
270
- const projects = [...sessions.values()].map(getSessionSummary);
271
- return {
272
- content: [
273
- {
274
- type: "text",
275
- text: JSON.stringify({ projects, selectedSessionId }, null, 2),
276
- },
277
- ],
278
- structuredContent: {
279
- projects,
280
- selectedSessionId,
281
- },
282
- };
283
- },
284
- );
285
-
286
- server.registerTool(
287
- "select_project",
288
- {
289
- description:
290
- "Choose which connected shader graph tab future MCP calls should target.",
291
- inputSchema: {
292
- sessionId: z.string().describe("Session id returned by list_projects."),
293
- },
294
- outputSchema: {
295
- sessionId: z.string(),
296
- project: z.any(),
297
- },
298
- },
299
- async ({ sessionId }) => {
300
- const session = ensureSession(sessionId);
301
- selectedSessionId = sessionId;
302
- const result = {
303
- sessionId,
304
- project: session.project,
305
- };
306
- return {
307
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
308
- structuredContent: result,
309
- };
310
- },
311
- );
312
-
313
- server.registerTool(
314
- "get_project_manifest",
315
- {
316
- description:
317
- "Get the machine-readable API manifest for the selected project.",
318
- inputSchema: {
319
- sessionId: z
320
- .string()
321
- .optional()
322
- .describe("Optional session id; defaults to the selected project."),
323
- },
324
- outputSchema: {
325
- sessionId: z.string(),
326
- project: z.any(),
327
- manifest: z.any(),
328
- },
329
- },
330
- async ({ sessionId }) => {
331
- const session = sessionId
332
- ? ensureSession(sessionId)
333
- : ensureSelectedSession();
334
- const result = {
335
- sessionId: session.sessionId,
336
- project: session.project,
337
- manifest: session.manifest,
338
- };
339
- return {
340
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
341
- structuredContent: result,
342
- };
343
- },
344
- );
345
-
346
- server.registerTool(
347
- "call_project_method",
348
- {
349
- description:
350
- "Call one method from the selected project's shaderGraphAPI and return its exact result.",
351
- inputSchema: {
352
- sessionId: z
353
- .string()
354
- .optional()
355
- .describe("Optional session id; defaults to the selected project."),
356
- method: z
357
- .string()
358
- .describe("Manifest method path, for example nodes.create or shader.getInfo."),
359
- args: z
360
- .array(z.any())
361
- .optional()
362
- .describe("Positional arguments to pass to the API method."),
363
- },
364
- outputSchema: {
365
- sessionId: z.string(),
366
- project: z.any(),
367
- method: z.string(),
368
- args: z.array(z.any()),
369
- durationMs: z.number(),
370
- result: z.any(),
371
- },
372
- },
373
- async ({ sessionId, method, args = [] }) => {
374
- const session = sessionId
375
- ? ensureSession(sessionId)
376
- : ensureSelectedSession();
377
- const response = await invokeSession(session, method, args);
378
- const result = {
379
- sessionId: session.sessionId,
380
- project: session.project,
381
- method,
382
- args,
383
- durationMs: response.durationMs ?? 0,
384
- result: response.result,
385
- };
386
- return {
387
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
388
- structuredContent: result,
389
- };
390
- },
391
- );
392
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();
393
738
  const transport = new StdioServerTransport();
394
739
  await server.connect(transport);