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/README.md +31 -0
- package/caw-icon-512.png +0 -0
- package/package.json +2 -2
- package/src/guidance/skill.md +472 -35
- package/src/server.mjs +615 -270
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
553
|
+
if (!activeSessionId) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
152
556
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
557
|
+
const session = sessions.get(activeSessionId);
|
|
558
|
+
if (!session) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
157
561
|
|
|
158
|
-
|
|
562
|
+
session.updatedAt = nowIso();
|
|
159
563
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
if (!pending) {
|
|
591
|
+
socket.on("close", () => {
|
|
592
|
+
if (!activeSessionId) {
|
|
169
593
|
return;
|
|
170
594
|
}
|
|
171
595
|
|
|
172
|
-
|
|
173
|
-
session
|
|
596
|
+
const session = sessions.get(activeSessionId);
|
|
597
|
+
if (!session) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
174
600
|
|
|
175
|
-
|
|
176
|
-
pending.
|
|
177
|
-
|
|
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
|
-
|
|
188
|
-
if (!activeSessionId) {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
615
|
+
log(`bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
|
|
191
616
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
617
|
+
controlServer = net.createServer((socket) => {
|
|
618
|
+
const rl = readline.createInterface({ input: socket });
|
|
196
619
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
629
|
+
if (!request || request.type !== "rpc") {
|
|
630
|
+
sendJson(socket, { ok: false, error: "Invalid control request" });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
206
633
|
|
|
207
|
-
|
|
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);
|