construct-shader-graph-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ <p align="center">
2
+ <img src="./caw-icon-512.png" alt="Construct Shader Graph MCP icon" width="128" height="128">
3
+ </p>
4
+
5
+ # Construct Shader Graph MCP
6
+
7
+ Standalone MCP server for controlling Construct Shader Graph through its browser bridge.
8
+
9
+ It exposes project discovery, manifest inspection, and exact method execution for the live app, while also bundling the guidance the model needs to work well with the tool.
10
+
11
+ Construct Shader Graph is a visual editor for building Construct effect shaders as node graphs. You can find the app here:
12
+
13
+ - `https://skymen.github.io/construct-shader-graph/`
14
+
15
+ ## Features
16
+
17
+ - MCP tools for project discovery and method execution
18
+ - local WebSocket bridge on `ws://127.0.0.1:6359` by default
19
+ - built-in skill guidance available directly from the MCP
20
+ - works with hosts like Claude Desktop and OpenCode
21
+
22
+ ## MCP tools
23
+
24
+ - `get_skill_guidance`
25
+ - `list_projects`
26
+ - `select_project`
27
+ - `get_project_manifest`
28
+ - `call_project_method`
29
+
30
+ ## Install as a package
31
+
32
+ Global install:
33
+
34
+ ```bash
35
+ npm install -g construct-shader-graph-mcp
36
+ ```
37
+
38
+ Run after installing globally:
39
+
40
+ ```bash
41
+ construct-shader-graph-mcp
42
+ ```
43
+
44
+ Or run without installing globally:
45
+
46
+ ```bash
47
+ npx -y construct-shader-graph-mcp
48
+ ```
49
+
50
+ ## Local development
51
+
52
+ Clone the repo and install dependencies:
53
+
54
+ ```bash
55
+ git clone https://github.com/skymen/construct-shader-graph-mcp.git
56
+ cd construct-shader-graph-mcp
57
+ npm install
58
+ ```
59
+
60
+ Run locally:
61
+
62
+ ```bash
63
+ npm start
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ Optional environment variable:
69
+
70
+ - `MCP_BRIDGE_PORT` to change the browser bridge port from `6359`
71
+
72
+ Example:
73
+
74
+ ```bash
75
+ MCP_BRIDGE_PORT=6360 construct-shader-graph-mcp
76
+ ```
77
+
78
+ ## How it works
79
+
80
+ There are two sides to the integration:
81
+
82
+ 1. The MCP host launches this package over stdio.
83
+ 2. The Construct Shader Graph page connects to the local WebSocket bridge.
84
+
85
+ The page should:
86
+
87
+ - connect to `ws://127.0.0.1:6359` by default
88
+ - register itself with project metadata from `shader.getInfo()`
89
+ - answer `invoke` messages with exact API return values
90
+
91
+ ## Claude Desktop setup
92
+
93
+ If installed globally:
94
+
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "construct-shader-graph": {
99
+ "command": "construct-shader-graph-mcp"
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ If using `npx`:
106
+
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "construct-shader-graph": {
111
+ "command": "npx",
112
+ "args": ["-y", "construct-shader-graph-mcp"]
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ ## OpenCode setup
119
+
120
+ Use the same command shape in your MCP configuration.
121
+
122
+ Global install example:
123
+
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "construct-shader-graph": {
128
+ "command": "construct-shader-graph-mcp"
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ `npx` example:
135
+
136
+ ```json
137
+ {
138
+ "mcpServers": {
139
+ "construct-shader-graph": {
140
+ "command": "npx",
141
+ "args": ["-y", "construct-shader-graph-mcp"]
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ ## Typical usage flow
148
+
149
+ 1. Start the MCP server from your host.
150
+ 2. Open Construct Shader Graph.
151
+ 3. In the app, connect to the MCP bridge from the Help menu.
152
+ 4. The host can now:
153
+ - call `list_projects`
154
+ - select the right project with `select_project`
155
+ - inspect available methods with `get_project_manifest`
156
+ - execute API calls with `call_project_method`
157
+
158
+ ## Publish notes
159
+
160
+ This package is configured for npm publishing with:
161
+
162
+ - package name: `construct-shader-graph-mcp`
163
+ - CLI binary: `construct-shader-graph-mcp`
164
+ - limited published files through the `files` field
165
+
166
+ Check package contents before publishing:
167
+
168
+ ```bash
169
+ npm run pack:check
170
+ ```
171
+
172
+ Publish publicly:
173
+
174
+ ```bash
175
+ npm publish
176
+ ```
177
+
178
+ ## Repo layout
179
+
180
+ - `src/server.mjs` - MCP server and bridge
181
+ - `src/guidance/skill.md` - bundled AI guidance and best practices
182
+ - `bin/construct-shader-graph-mcp.js` - CLI entrypoint
183
+ - `caw-icon.png` - package/readme icon
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "../src/server.mjs";
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "construct-shader-graph-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Standalone MCP server for Construct Shader Graph",
5
+ "type": "module",
6
+ "files": [
7
+ "bin",
8
+ "src",
9
+ "README.md",
10
+ "caw-icon.png"
11
+ ],
12
+ "bin": {
13
+ "construct-shader-graph-mcp": "./bin/construct-shader-graph-mcp.js"
14
+ },
15
+ "scripts": {
16
+ "start": "node ./bin/construct-shader-graph-mcp.js",
17
+ "dev": "node ./src/server.mjs",
18
+ "pack:check": "npm pack --dry-run"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "construct",
23
+ "shader-graph",
24
+ "claude",
25
+ "opencode"
26
+ ],
27
+ "author": "skymen",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/skymen/construct-shader-graph-mcp.git"
32
+ },
33
+ "homepage": "https://github.com/skymen/construct-shader-graph-mcp#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/skymen/construct-shader-graph-mcp/issues"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.27.1",
39
+ "ws": "^8.19.0",
40
+ "zod": "^4.3.6"
41
+ }
42
+ }
@@ -0,0 +1,97 @@
1
+ # Construct Shader Graph MCP Skill
2
+
3
+ Use this guidance when working with Construct Shader Graph through the MCP bridge.
4
+
5
+ ## Purpose
6
+
7
+ - Use the MCP tools as the only execution surface.
8
+ - Inspect the current graph, make targeted graph edits, validate the result, and report progress clearly.
9
+ - Treat the graph as the source of truth for shader logic. Use preview controls only to inspect or demonstrate the result.
10
+
11
+ ## MCP tool contract
12
+
13
+ Use these tools for all work:
14
+
15
+ - `list_projects`
16
+ - `select_project`
17
+ - `get_project_manifest`
18
+ - `call_project_method`
19
+
20
+ Execution rules:
21
+
22
+ - Always use MCP tools instead of browser console access.
23
+ - Always identify the active project from shader metadata returned by `shader.getInfo()`.
24
+ - Always use exact return values from MCP calls; do not guess state.
25
+ - Always read the manifest when capabilities or argument shapes are unclear.
26
+
27
+ ## Operating contract
28
+
29
+ - Preserve existing user work unless the task clearly requires replacing it.
30
+ - Identify the correct connected project before mutating anything.
31
+ - Inspect first, mutate second.
32
+ - Make the smallest valid change that satisfies the request.
33
+ - Verify after every structural edit such as creating nodes, deleting nodes, or wiring ports.
34
+ - Use stable ids from API results; do not rely on labels, visual position, or selection alone.
35
+ - Do not open arbitrary local files or save project files autonomously.
36
+ - Built-in examples are safe to open.
37
+ - Export is allowed because it triggers a download rather than silently overwriting a project.
38
+
39
+ ## Preferred workflow
40
+
41
+ 1. Call `list_projects`.
42
+ 2. Select the correct project with `select_project`.
43
+ 3. Read `get_project_manifest` once per task or when capabilities are unclear.
44
+ 4. Start the session.
45
+ 5. Inspect current graph state.
46
+ 6. Identify exact node ids, port refs, uniform ids, or settings keys.
47
+ 7. Apply one atomic edit or one tightly related batch.
48
+ 8. Re-read the affected nodes, ports, wires, or settings.
49
+ 9. Check preview or generated code if relevant.
50
+ 10. Repeat only if needed.
51
+ 11. End the session with a recap.
52
+
53
+ ## Core rules
54
+
55
+ - Always call `session.initAIWork()` when starting a task.
56
+ - Always call `session.endAIWork()` when finishing a task.
57
+ - Use `session.updateAIWork()` only for short phase updates.
58
+ - Always inspect preview errors after meaningful shader edits.
59
+ - Always use preview and screenshots for non-trivial visual validation.
60
+ - Prefer setting editable input port values directly before adding constant/vector nodes.
61
+ - Never assume a node id, port index, or wire id without reading it first.
62
+ - Never connect ports without checking the actual node ports.
63
+ - Never replace an output connection blindly; inspect the affected ports first.
64
+ - Never use startup scripts as a substitute for graph logic.
65
+ - Never create or edit custom node definitions unless explicitly asked.
66
+
67
+ ## Graph editing guidance
68
+
69
+ - Always inspect ports before creating wires.
70
+ - Use explicit port refs: `{ nodeId, kind, index }`.
71
+ - Prefer `index` over `name` for automation stability.
72
+ - Use `declaredType` and `resolvedType` to understand generic or dynamic nodes.
73
+ - If an input port is editable and unconnected, prefer setting its value directly instead of creating a separate constant node.
74
+ - If one output would feed many distant nodes, prefer variables instead of many long wires.
75
+
76
+ ## Preview and verification guidance
77
+
78
+ - Default preview compiles from `Output`.
79
+ - Use node preview for masks, UVs, gradients, lighting terms, and intermediate values.
80
+ - Use the preview console as part of the normal debug loop.
81
+ - Use screenshots to confirm that the visual result matches the intent.
82
+ - Prefer `ai.runDebugCheck()` for combined validation.
83
+
84
+ ## Construct-specific guidance
85
+
86
+ - Important shader settings include `blendsBackground`, `usesDepth`, `crossSampling`, `animated`, `mustPredraw`, `supports3DDirectRendering`, `extendBoxH`, and `extendBoxV`.
87
+ - Background sampling only makes sense when `blendsBackground` is enabled.
88
+ - Depth sampling only makes sense when `usesDepth` is enabled.
89
+ - Construct uses premultiplied alpha, so many color workflows should use `unpremultiply` before edits and `premultiply` before output.
90
+ - Prefer existing Construct helper nodes instead of rebuilding common math manually.
91
+
92
+ ## Troubleshooting
93
+
94
+ - If no projects are listed, make sure the page is connected to the MCP bridge.
95
+ - If the wrong project is selected, compare `shader.getInfo()` metadata and reselect.
96
+ - If wire creation fails, inspect both nodes with `nodes.getPorts` and check `resolvedType`.
97
+ - If preview looks wrong, inspect preview settings, preview errors, node preview, and screenshots.
package/src/server.mjs ADDED
@@ -0,0 +1,394 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { WebSocketServer } from "ws";
7
+ import { z } from "zod";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ const BRIDGE_PORT = Number(process.env.MCP_BRIDGE_PORT || 6359);
13
+ const SKILL_PATH = path.resolve(__dirname, "guidance/skill.md");
14
+
15
+ const sessions = new Map();
16
+ let selectedSessionId = null;
17
+
18
+ function log(message, ...args) {
19
+ console.error(`[construct-shader-graph-mcp] ${message}`, ...args);
20
+ }
21
+
22
+ function nowIso() {
23
+ return new Date().toISOString();
24
+ }
25
+
26
+ function loadSkillText() {
27
+ return fs.readFileSync(SKILL_PATH, "utf8");
28
+ }
29
+
30
+ function getSessionSummary(session) {
31
+ return {
32
+ sessionId: session.sessionId,
33
+ project: session.project,
34
+ connectedAt: session.connectedAt,
35
+ updatedAt: session.updatedAt,
36
+ manifestVersion: session.manifest?.version || null,
37
+ methodCount: Array.isArray(session.manifest?.methods)
38
+ ? session.manifest.methods.length
39
+ : 0,
40
+ selected: selectedSessionId === session.sessionId,
41
+ };
42
+ }
43
+
44
+ function ensureSession(sessionId) {
45
+ const session = sessions.get(sessionId);
46
+ if (!session) {
47
+ throw new Error(`Unknown session '${sessionId}'`);
48
+ }
49
+ return session;
50
+ }
51
+
52
+ function ensureSelectedSession() {
53
+ if (!selectedSessionId) {
54
+ throw new Error("No project selected. Call select_project first.");
55
+ }
56
+
57
+ return ensureSession(selectedSessionId);
58
+ }
59
+
60
+ function sendJson(socket, payload) {
61
+ socket.send(JSON.stringify(payload));
62
+ }
63
+
64
+ function invokeSession(session, method, args = []) {
65
+ return new Promise((resolve, reject) => {
66
+ if (session.socket.readyState !== session.socket.OPEN) {
67
+ reject(new Error(`Session '${session.sessionId}' is not connected`));
68
+ return;
69
+ }
70
+
71
+ const requestId = `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
72
+ const timeoutId = setTimeout(() => {
73
+ session.pending.delete(requestId);
74
+ reject(
75
+ new Error(
76
+ `Timed out waiting for '${method}' result from session '${session.sessionId}'`,
77
+ ),
78
+ );
79
+ }, 15000);
80
+
81
+ session.pending.set(requestId, {
82
+ resolve,
83
+ reject,
84
+ timeoutId,
85
+ method,
86
+ });
87
+
88
+ sendJson(session.socket, {
89
+ type: "invoke",
90
+ requestId,
91
+ method,
92
+ args,
93
+ });
94
+ });
95
+ }
96
+
97
+ const bridge = new WebSocketServer({ host: "127.0.0.1", port: BRIDGE_PORT });
98
+
99
+ bridge.on("connection", (socket) => {
100
+ let activeSessionId = null;
101
+
102
+ socket.on("message", (raw) => {
103
+ let message;
104
+ try {
105
+ message = JSON.parse(String(raw));
106
+ } catch {
107
+ return;
108
+ }
109
+
110
+ if (!message || typeof message !== "object") {
111
+ return;
112
+ }
113
+
114
+ if (message.type === "register") {
115
+ const sessionId = String(message.sessionId || "").trim();
116
+ if (!sessionId) {
117
+ sendJson(socket, { type: "error", message: "Missing sessionId" });
118
+ return;
119
+ }
120
+
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;
138
+ }
139
+
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
+ }
148
+
149
+ if (!activeSessionId) {
150
+ return;
151
+ }
152
+
153
+ const session = sessions.get(activeSessionId);
154
+ if (!session) {
155
+ return;
156
+ }
157
+
158
+ session.updatedAt = nowIso();
159
+
160
+ if (message.type === "project-updated") {
161
+ session.project = message.project || session.project;
162
+ session.manifest = message.manifest || session.manifest;
163
+ return;
164
+ }
165
+
166
+ if (message.type === "result") {
167
+ const pending = session.pending.get(message.requestId);
168
+ if (!pending) {
169
+ return;
170
+ }
171
+
172
+ clearTimeout(pending.timeoutId);
173
+ session.pending.delete(message.requestId);
174
+
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);
183
+ }
184
+ }
185
+ });
186
+
187
+ socket.on("close", () => {
188
+ if (!activeSessionId) {
189
+ return;
190
+ }
191
+
192
+ const session = sessions.get(activeSessionId);
193
+ if (!session) {
194
+ return;
195
+ }
196
+
197
+ for (const pending of session.pending.values()) {
198
+ clearTimeout(pending.timeoutId);
199
+ pending.reject(new Error(`Session '${activeSessionId}' disconnected`));
200
+ }
201
+
202
+ sessions.delete(activeSessionId);
203
+ if (selectedSessionId === activeSessionId) {
204
+ selectedSessionId = sessions.keys().next().value || null;
205
+ }
206
+
207
+ log(`disconnected ${activeSessionId}`);
208
+ });
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
+
393
+ const transport = new StdioServerTransport();
394
+ await server.connect(transport);