Add shadertoy-server example for WebGL shader rendering (#226)

Introduces a new MCP App example that renders ShaderToy-compatible GLSL
fragment shaders in real-time using WebGL 2.0 and ShaderToyLite.js.

Features:
- Standard ShaderToy uniforms (`iTime`, `iResolution`, `iMouse`, etc.)
- Multi-pass rendering with buffers A-D for feedback effects
- Shader compilation error overlay

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2026-01-12 12:26:02 -06:00 • committed by GitHub
commit ec3409aa91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1641 additions and 1901 deletions

View file

@ -0,0 +1,12 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
}
code {
font-size: 1em;
}

View file

@ -0,0 +1,12 @@
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}

View file

@ -0,0 +1,126 @@
/**
* ShaderToy renderer MCP App using ShaderToyLite.js
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import "./global.css";
import "./mcp-app.css";
import ShaderToyLite, {
type ShaderToyLiteInstance,
} from "./vendor/ShaderToyLite.js";
interface ShaderInput {
fragmentShader: string;
common?: string;
bufferA?: string;
bufferB?: string;
bufferC?: string;
bufferD?: string;
}
function isShaderInput(value: unknown): value is ShaderInput {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).fragmentShader === "string"
);
}
const log = {
info: console.log.bind(console, "[APP]"),
warn: console.warn.bind(console, "[APP]"),
error: console.error.bind(console, "[APP]"),
};
// Get element references
const mainEl = document.querySelector(".main") as HTMLElement;
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
// Resize canvas to fill viewport
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// Handle host context changes (safe area insets)
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
// ShaderToyLite instance
let shaderToy: ShaderToyLiteInstance | null = null;
// Create app instance
const app = new App({ name: "ShaderToy Renderer", version: "1.0.0" });
app.onteardown = async () => {
log.info("App is being torn down");
if (shaderToy) {
shaderToy.pause();
}
return {};
};
app.ontoolinput = (params) => {
log.info("Received shader input");
if (!isShaderInput(params.arguments)) {
log.error("Invalid tool input");
return;
}
const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } =
params.arguments;
// Initialize ShaderToyLite if needed
if (!shaderToy) {
shaderToy = new ShaderToyLite("canvas");
}
// Set common code (shared across all shaders)
shaderToy.setCommon(common || "");
// Set buffer shaders with self-feedback
if (bufferA) {
shaderToy.setBufferA({ source: bufferA, iChannel0: "A" });
}
if (bufferB) {
shaderToy.setBufferB({ source: bufferB, iChannel1: "B" });
}
if (bufferC) {
shaderToy.setBufferC({ source: bufferC, iChannel2: "C" });
}
if (bufferD) {
shaderToy.setBufferD({ source: bufferD, iChannel3: "D" });
}
// Set main Image shader with buffer inputs
shaderToy.setImage({
source: fragmentShader,
iChannel0: bufferA ? "A" : undefined,
iChannel1: bufferB ? "B" : undefined,
iChannel2: bufferC ? "C" : undefined,
iChannel3: bufferD ? "D" : undefined,
});
shaderToy.play();
log.info("Setup complete");
};
app.onerror = log.error;
app.onhostcontextchanged = handleHostContextChanged;
// Connect to host
app.connect().then(() => {
log.info("Connected to host");
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});

View file

@ -0,0 +1,113 @@
/**
* Type declarations for ShaderToyLite.js
* https://github.com/chipweinberger/ShaderToyLite.js
*/
/** Valid buffer keys for channel inputs */
type BufferKey = "A" | "B" | "C" | "D";
/** Configuration for shader passes (buffers and image) */
interface ShaderConfig {
/** GLSL fragment shader source code */
source?: string;
/** Buffer to use as iChannel0 input */
iChannel0?: BufferKey;
/** Buffer to use as iChannel1 input */
iChannel1?: BufferKey;
/** Buffer to use as iChannel2 input */
iChannel2?: BufferKey;
/** Buffer to use as iChannel3 input */
iChannel3?: BufferKey;
}
/** ShaderToyLite instance methods */
interface ShaderToyLiteInstance {
/**
* Set common shader code shared across all passes.
* @param source - GLSL source code to prepend to all shaders
*/
setCommon(source?: string | null): void;
/**
* Set Buffer A shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferA(config?: ShaderConfig | null): void;
/**
* Set Buffer B shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferB(config?: ShaderConfig | null): void;
/**
* Set Buffer C shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferC(config?: ShaderConfig | null): void;
/**
* Set Buffer D shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferD(config?: ShaderConfig | null): void;
/**
* Set the main Image shader pass (renders to screen).
* @param config - Shader configuration or falsy to clear
*/
setImage(config?: ShaderConfig | null): void;
/**
* Set a callback to be called on each draw frame.
* @param callback - Function to call each frame
*/
setOnDraw(callback?: (() => void) | null): void;
/**
* Add a custom texture that can be used as a channel input.
* @param texture - WebGL texture object
* @param key - Buffer key to reference this texture (only A/B/C/D are recognized by iChannel bindings)
*/
addTexture(texture: WebGLTexture, key: BufferKey): void;
/**
* Get the current playback time in seconds.
* @returns Time elapsed since first draw
*/
time(): number;
/**
* Check if the shader is currently playing.
* @returns True if playing, false if paused
*/
isPlaying(): boolean;
/**
* Reset playback to the beginning (time = 0, frame = 0).
*/
reset(): void;
/**
* Pause shader playback.
*/
pause(): void;
/**
* Start or resume shader playback.
*/
play(): void;
}
/** ShaderToyLite constructor interface */
interface ShaderToyLiteConstructor {
/**
* Create a new ShaderToyLite instance.
* @param canvasId - ID of the canvas element to render to
*/
new (canvasId: string): ShaderToyLiteInstance;
}
declare const ShaderToyLite: ShaderToyLiteConstructor;
export default ShaderToyLite;
export type { ShaderToyLiteInstance, ShaderConfig, BufferKey };

View file

@ -0,0 +1,437 @@
/**
* ShaderToyLite.js
* https://github.com/chipweinberger/ShaderToyLite.js
*
* Copyright (c) Chip Weinberger
* Licensed under the MIT License
* https://github.com/chipweinberger/ShaderToyLite.js/blob/master/LICENSE
*/
function ShaderToyLite(canvasId) {
var hdr =
`#version 300 es
#ifdef GL_ES
precision highp float;
precision highp int;
precision mediump sampler3D;
#endif
#define texture2D texture
uniform vec3 iResolution; // viewport resolution (in pixels)
uniform float iTime; // shader playback time (in seconds)
uniform float iTimeDelta; // render time (in seconds)
uniform float iFrameRate; // shader frame rate
uniform int iFrame; // shader playback frame
uniform float iChannelTime[4]; // channel playback time (in seconds)
uniform vec3 iChannelResolution[4]; // channel resolution (in pixels)
uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click
uniform sampler2D iChannel0; // input channel 0
uniform sampler2D iChannel1; // input channel 1
uniform sampler2D iChannel2; // input channel 2
uniform sampler2D iChannel3; // input channel 3
uniform vec4 iDate; // (year, month, day, unixtime in seconds)
uniform float iSampleRate; // sound sample rate (i.e., 44100)
out vec4 frag_out_color;
void mainImage( out vec4 c, in vec2 f );
void main( void )
{
vec4 color = vec4(0.0,0.0,0.0,0.0);
mainImage( color, gl_FragCoord.xy );
frag_out_color = vec4(color);
}
`;
var basicFragShader =
`void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
fragColor = texture2D(iChannel0, gl_FragCoord.xy / iResolution.xy);
}
`;
const basicVertexShader =
`#version 300 es
#ifdef GL_ES
precision highp float;
precision highp int;
precision mediump sampler3D;
#endif
in vec2 vertexInPosition;
void main() {
gl_Position = vec4(vertexInPosition, 0.0, 1.0);
}
`;
const quadVertices = new Float32Array([
-1.0, -1.0,
1.0, -1.0,
-1.0, 1.0,
1.0, 1.0,
-1.0, 1.0,
1.0, -1.0
]);
var opts = { alpha: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
antialias: true,
preserveDrawingBuffer: false,
powerPreference: "high-performance" };
var gl = document.getElementById(canvasId).getContext('webgl2', opts);
// timing
var isPlaying = false;
var firstDrawTime = 0;
var prevDrawTime = 0;
// callback
var onDrawCallback;
// uniforms
var iFrame = 0;
var iMouse = {x: 0, y: 0, clickX: 0, clickY: 0};
// shader common source
var common = "";
// render passes variables. valid keys:
// 'A', 'B', 'C', 'D', 'Image'
var sourcecode = {};// fragment shader code
var ichannels = {}; // texture inputs
var atexture = {}; // front texture (input/output)
var btexture = {}; // back texture (input/output)
var aframebuf = {}; // front buffer (output)
var bframebuf = {}; // back buffer (output)
var program = {}; // webgl program
var location = {}; // uniform location
var flip = {}; // a b flip
var quadBuffer; // two full screen triangles
var setup = () => {
gl.getExtension( 'OES_texture_float_linear');
gl.getExtension( 'OES_texture_half_float_linear');
gl.getExtension( 'EXT_color_buffer_float');
gl.getExtension( 'WEBGL_debug_shaders');
['A', 'B', 'C', 'D', 'Image'].forEach((key) => {
sourcecode[key] = "";
ichannels[key] = {};
program[key] = null;
location[key] = {};
if (key != 'Image') {
atexture[key] = createTexture();
btexture[key] = createTexture();
aframebuf[key] = createFrameBuffer(atexture[key]);
bframebuf[key] = createFrameBuffer(btexture[key]);
flip[key] = false;
}
});
// bind the geometry
quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW);
// Set viewport size
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
var canvas = document.getElementById(canvasId);
window.addEventListener('resize', function() {
gl.canvas.width = canvas.width;
gl.canvas.height = canvas.height;
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
});
canvas.addEventListener("mousemove", (event) => {
iMouse.x = event.offsetX;
iMouse.y = canvas.height - event.offsetY;
});
canvas.addEventListener("mousedown", (event) => {
iMouse.clickX = event.offsetX;
iMouse.clickY = canvas.height - event.offsetY;
});
canvas.addEventListener("mouseup", () => {
iMouse.clickX = 0;
iMouse.clickY = 0;
});
}
var createTexture = () => {
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, gl.canvas.width, gl.canvas.height, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
var createFrameBuffer = (texture) => {
var framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return framebuffer;
};
var compileProgram = (key) => {
var vert = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vert, basicVertexShader);
gl.compileShader(vert);
if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) {
console.error('Vertex Shader compilation failed: ' + gl.getShaderInfoLog(vert));
gl.deleteShader(vert);
return null;
}
var source = hdr + common + sourcecode[key];
var frag = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(frag, source);
gl.compileShader(frag);
if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) {
console.error('Fragment Shader compilation failed: ' + gl.getShaderInfoLog(frag));
console.error(source);
gl.deleteShader(frag);
return null;
}
var program = gl.createProgram();
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program initialization failed: ' + gl.getProgramInfoLog(program));
return null;
}
// uniform locations
location[key]["iResolution"] = gl.getUniformLocation(program, "iResolution");
location[key]["iTime"] = gl.getUniformLocation(program, "iTime");
location[key]["iTimeDelta"] = gl.getUniformLocation(program, "iTimeDelta");
location[key]["iFrameRate"] = gl.getUniformLocation(program, "iFrameRate");
location[key]["iFrame"] = gl.getUniformLocation(program, "iFrame");
location[key]["iChannelTime"] = gl.getUniformLocation(program, "iChannelTime[0]");
location[key]["iChannelResolution"] = gl.getUniformLocation(program, "iChannelResolution[0]");
location[key]["iChannel0"] = gl.getUniformLocation(program, "iChannel0");
location[key]["iChannel1"] = gl.getUniformLocation(program, "iChannel1");
location[key]["iChannel2"] = gl.getUniformLocation(program, "iChannel2");
location[key]["iChannel3"] = gl.getUniformLocation(program, "iChannel3");
location[key]["iMouse"] = gl.getUniformLocation(program, "iMouse");
location[key]["iDate"] = gl.getUniformLocation(program, "iDate");
location[key]["iSampleRate"] = gl.getUniformLocation(program, "iSampleRate");
location[key]["vertexInPosition"] = gl.getAttribLocation(program, "vertexInPosition");
return program;
};
var repeat = (times, arr) => {
let result = [];
for (let i = 0; i < times; i++) {
result = [...result, ...arr];
}
return result;
}
var setShader = (config, key) => {
if (config) {
if (config.source) {
sourcecode[key] = config.source;
program[key] = compileProgram(key);
if (program[key] == null) {
console.error("Failed to compile " + key);
}
}
for (let i = 0; i < 4; i++) {
var s = config[`iChannel${i}`];
if (s == "A" || s == "B" || s == "C" || s == "D") {
ichannels[key][i] = s;
}
}
} else {
sourcecode[key] = "";
program[key] = null;
}
};
var draw = () => {
// current time
var now = isPlaying ? Date.now() : prevDrawTime;
var date = new Date(now);
// first draw?
if (firstDrawTime == 0) {
firstDrawTime = now;
}
// call callback
if (onDrawCallback) {
onDrawCallback();
}
// time difference between frames in seconds
var iTimeDelta = (now - prevDrawTime) * 0.001;
// time in seconds
var iTime = (now - firstDrawTime) * 0.001;
var iDate = [date.getFullYear(), date.getMonth(), date.getDate(), date.getTime() * 0.001];
// channel uniforms
var iChannelTimes = new Float32Array(repeat(4, [iTime]));
var iChannelResolutions = new Float32Array(repeat(4, [gl.canvas.width, gl.canvas.height, 0]));
['A', 'B', 'C', 'D', 'Image'].forEach((key) => {
if (program[key]) {
// framebuffer
if (key === "Image") {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
} else {
var output = flip[key] ? bframebuf[key] : aframebuf[key];
gl.bindFramebuffer(gl.FRAMEBUFFER, output);
}
// textures
for (let i = 0; i < 4; i++) {
var chkey = ichannels[key][i];
if (chkey) {
var input = flip[chkey] ? atexture[chkey] : btexture[chkey];
gl.activeTexture(gl[`TEXTURE${i}`]);
gl.bindTexture(gl.TEXTURE_2D, input);
}
}
// program
gl.useProgram(program[key]);
// uniforms
gl.uniform3f( location[key]["iResolution"], gl.canvas.width, gl.canvas.height, 1.0);
gl.uniform1f( location[key]["iTime"], iTime);
gl.uniform1f( location[key]["iTimeDelta"], iTimeDelta);
gl.uniform1f( location[key]["iFrameRate"], 60);
gl.uniform1i( location[key]["iFrame"], iFrame);
gl.uniform1fv(location[key]["iChannelTime"], iChannelTimes);
gl.uniform3fv(location[key]["iChannelResolution"], iChannelResolutions);
gl.uniform1i( location[key]["iChannel0"], 0);
gl.uniform1i( location[key]["iChannel1"], 1);
gl.uniform1i( location[key]["iChannel2"], 2);
gl.uniform1i( location[key]["iChannel3"], 3);
gl.uniform4f( location[key]["iMouse"], iMouse.x, iMouse.y, iMouse.clickX, iMouse.clickY);
gl.uniform4f( location[key]["iDate"], iDate[0], iDate[1], iDate[2], iDate[3]);
gl.uniform1f( location[key]["iSampleRate"], 44100);
// viewport
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// vertexs
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.vertexAttribPointer(location[key]["vertexInPosition"], 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(location[key]["vertexInPosition"]);
// draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
flip[key] = !flip[key];
}
});
// time of last draw
prevDrawTime = now;
// frame counter
iFrame++;
};
// Animation loop
var animate = () => {
if (isPlaying) {
draw();
requestAnimationFrame(animate);
}
};
this.setCommon = (source) => {
if (source === undefined) {source = "";}
if (source === null) {source = "";}
common = source;
['A', 'B', 'C', 'D', 'Image'].forEach((key) => {
if (program[key]) {
program[key] = compileProgram(key);
}
});
};
this.setBufferA = (config) => {
setShader(config, 'A');
};
this.setBufferB = (config) => {
setShader(config, 'B');
};
this.setBufferC = (config) => {
setShader(config, 'C');
};
this.setBufferD = (config) => {
setShader(config, 'D');
};
this.setImage = (config) => {
setShader(config, 'Image');
};
this.setOnDraw = (callback) => {
onDrawCallback = callback;
}
this.addTexture = (texture, key) => {
atexture[key] = texture;
btexture[key] = texture;
flip[key] = false;
}
this.time = () => {
return (prevDrawTime - firstDrawTime) * 0.001;
}
this.isPlaying = () => isPlaying;
this.reset = () => {
var now = new Date();
firstDrawTime = now;
prevDrawTime = now;
iFrame = 0;
draw();
}
this.pause = () => {
isPlaying = false;
}
this.play = () => {
if (!isPlaying) {
isPlaying = true;
var now = Date.now();
var elapsed = prevDrawTime - firstDrawTime;
firstDrawTime = now - elapsed;
prevDrawTime = now;
animate();
}
}
setup();
}
export default ShaderToyLite;