MCP Server (8 tools):
- browse_shaders: search by title, tags, type, sort (trending/new/top)
- get_shader: full details + GLSL source by ID
- get_shader_versions: version history with change notes
- get_shader_version_code: GLSL code from any specific version
- submit_shader: create new shader (published or draft)
- update_shader: push revisions with change notes, auto-versions
- get_trending: top-scored shaders
- get_desire_queue: open community requests
MCP resource: fractafrag://platform-info with shader format guide
Auth: Internal service token (Bearer internal:mcp-service) allows MCP
server to write to the API as the system user. No user API keys needed
for the MCP→API internal path.
Transport: Streamable HTTP on port 3200 via FastMCP SDK.
Stateless mode with JSON responses.
Hot Score Ranking:
- Wilson score lower bound with 48-hour time decay
- Recalculated on every vote (up/down/remove)
- Feed sorts by score for trending view
Connection config for Claude Desktop:
{
"mcpServers": {
"fractafrag": {
"url": "http://localhost:3200/mcp"
}
}
}
240 lines
9.7 KiB
Python
240 lines
9.7 KiB
Python
"""
|
|
Fractafrag MCP Server — AI agent interface to the shader platform.
|
|
|
|
Enables Claude, GPT, and other MCP clients to:
|
|
- Browse and search shaders
|
|
- Get full shader details by ID
|
|
- Submit new shaders
|
|
- Update existing shaders (push revisions)
|
|
- View version history
|
|
- Browse the desire queue
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import httpx
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
API_BASE = os.environ.get("API_BASE_URL", "http://api:8000")
|
|
INTERNAL_AUTH = {"Authorization": "Bearer internal:mcp-service"}
|
|
|
|
mcp = FastMCP(
|
|
"Fractafrag",
|
|
stateless_http=True,
|
|
json_response=True,
|
|
host="0.0.0.0",
|
|
port=3200,
|
|
)
|
|
|
|
|
|
async def api_get(path: str, params: dict | None = None):
|
|
async with httpx.AsyncClient(base_url=API_BASE, timeout=15.0) as client:
|
|
resp = await client.get(f"/api/v1{path}", params=params)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def api_post(path: str, data: dict):
|
|
async with httpx.AsyncClient(base_url=API_BASE, timeout=15.0) as client:
|
|
resp = await client.post(f"/api/v1{path}", json=data, headers=INTERNAL_AUTH)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def api_put(path: str, data: dict):
|
|
async with httpx.AsyncClient(base_url=API_BASE, timeout=15.0) as client:
|
|
resp = await client.put(f"/api/v1{path}", json=data, headers=INTERNAL_AUTH)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
@mcp.tool()
|
|
async def browse_shaders(query: str = "", tags: str = "", shader_type: str = "", sort: str = "trending", limit: int = 20) -> str:
|
|
"""Browse and search shaders on Fractafrag.
|
|
|
|
Args:
|
|
query: Search text (matches title)
|
|
tags: Comma-separated tag filter (e.g. "fractal,colorful")
|
|
shader_type: Filter by type: 2d, 3d, or audio-reactive
|
|
sort: Sort order: trending, new, or top
|
|
limit: Number of results (1-50)
|
|
"""
|
|
params: dict = {"sort": sort, "limit": min(limit, 50)}
|
|
if query: params["q"] = query
|
|
if tags: params["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
|
if shader_type: params["shader_type"] = shader_type
|
|
|
|
shaders = await api_get("/shaders", params)
|
|
results = [{"id": s["id"], "title": s["title"], "description": s.get("description", ""),
|
|
"shader_type": s["shader_type"], "tags": s.get("tags", []),
|
|
"score": s.get("score", 0), "view_count": s.get("view_count", 0),
|
|
"is_system": s.get("is_system", False), "current_version": s.get("current_version", 1)}
|
|
for s in shaders]
|
|
return json.dumps({"count": len(results), "shaders": results})
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_shader(shader_id: str) -> str:
|
|
"""Get full details of a shader by its ID, including GLSL source code.
|
|
|
|
Args:
|
|
shader_id: UUID of the shader
|
|
"""
|
|
s = await api_get(f"/shaders/{shader_id}")
|
|
return json.dumps({"id": s["id"], "title": s["title"], "description": s.get("description"),
|
|
"glsl_code": s["glsl_code"], "shader_type": s["shader_type"],
|
|
"tags": s.get("tags", []), "status": s.get("status"),
|
|
"is_system": s.get("is_system", False), "style_metadata": s.get("style_metadata"),
|
|
"current_version": s.get("current_version", 1),
|
|
"score": s.get("score", 0), "view_count": s.get("view_count", 0),
|
|
"forked_from": s.get("forked_from"), "created_at": s.get("created_at"),
|
|
"updated_at": s.get("updated_at")})
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_shader_versions(shader_id: str) -> str:
|
|
"""Get the version history of a shader.
|
|
|
|
Args:
|
|
shader_id: UUID of the shader
|
|
"""
|
|
versions = await api_get(f"/shaders/{shader_id}/versions")
|
|
return json.dumps({"shader_id": shader_id, "version_count": len(versions),
|
|
"versions": [{"version_number": v["version_number"], "title": v["title"],
|
|
"change_note": v.get("change_note"), "created_at": v["created_at"]}
|
|
for v in versions]})
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_shader_version_code(shader_id: str, version_number: int) -> str:
|
|
"""Get the GLSL code of a specific version of a shader.
|
|
|
|
Args:
|
|
shader_id: UUID of the shader
|
|
version_number: Version number to retrieve
|
|
"""
|
|
v = await api_get(f"/shaders/{shader_id}/versions/{version_number}")
|
|
return json.dumps({"shader_id": shader_id, "version_number": v["version_number"],
|
|
"title": v["title"], "glsl_code": v["glsl_code"],
|
|
"tags": v.get("tags", []), "change_note": v.get("change_note")})
|
|
|
|
|
|
@mcp.tool()
|
|
async def submit_shader(title: str, glsl_code: str, description: str = "", tags: str = "",
|
|
shader_type: str = "2d", status: str = "published") -> str:
|
|
"""Submit a new GLSL shader to Fractafrag.
|
|
|
|
Shader format: void mainImage(out vec4 fragColor, in vec2 fragCoord)
|
|
Uniforms: iTime (float), iResolution (vec3), iMouse (vec4)
|
|
|
|
Args:
|
|
title: Shader title (max 120 chars)
|
|
glsl_code: Complete GLSL fragment shader code
|
|
description: Optional description
|
|
tags: Comma-separated tags (e.g. "fractal,noise,colorful")
|
|
shader_type: 2d, 3d, or audio-reactive
|
|
status: "published" to go live, "draft" to save privately
|
|
"""
|
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
|
|
result = await api_post("/shaders", {"title": title, "glsl_code": glsl_code,
|
|
"description": description, "tags": tag_list,
|
|
"shader_type": shader_type, "status": status})
|
|
return json.dumps({"id": result["id"], "title": result["title"],
|
|
"status": result.get("status"), "current_version": result.get("current_version", 1),
|
|
"message": f"Shader '{result['title']}' created.", "url": f"/shader/{result['id']}"})
|
|
|
|
|
|
@mcp.tool()
|
|
async def update_shader(shader_id: str, glsl_code: str = "", title: str = "",
|
|
description: str = "", tags: str = "", status: str = "",
|
|
change_note: str = "") -> str:
|
|
"""Update an existing shader. Creates a new version in the history.
|
|
|
|
Use this to iterate — change code, adjust metadata, or publish a draft.
|
|
Every code change creates an immutable version snapshot.
|
|
|
|
Args:
|
|
shader_id: UUID of the shader to update
|
|
glsl_code: New GLSL code (empty = keep current)
|
|
title: New title (empty = keep current)
|
|
description: New description (empty = keep current)
|
|
tags: New comma-separated tags (empty = keep current)
|
|
status: Change status: draft, published, or archived
|
|
change_note: Brief note about what changed (e.g. "made it pinker")
|
|
"""
|
|
payload = {}
|
|
if glsl_code: payload["glsl_code"] = glsl_code
|
|
if title: payload["title"] = title
|
|
if description: payload["description"] = description
|
|
if tags: payload["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
|
if status: payload["status"] = status
|
|
if change_note: payload["change_note"] = change_note
|
|
if not payload:
|
|
return json.dumps({"error": "No changes provided."})
|
|
|
|
result = await api_put(f"/shaders/{shader_id}", payload)
|
|
return json.dumps({"id": result["id"], "title": result["title"], "status": result.get("status"),
|
|
"current_version": result.get("current_version"),
|
|
"message": f"Updated to v{result.get('current_version', '?')}.",
|
|
"url": f"/shader/{result['id']}"})
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_trending(limit: int = 10) -> str:
|
|
"""Get currently trending shaders.
|
|
|
|
Args:
|
|
limit: Number of results (1-50)
|
|
"""
|
|
shaders = await api_get("/feed/trending", {"limit": min(limit, 50)})
|
|
return json.dumps({"count": len(shaders),
|
|
"shaders": [{"id": s["id"], "title": s["title"], "shader_type": s["shader_type"],
|
|
"tags": s.get("tags", []), "score": s.get("score", 0)}
|
|
for s in shaders]})
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_desire_queue(min_heat: float = 0, limit: int = 10) -> str:
|
|
"""Get open shader desires/bounties. These are community requests.
|
|
|
|
Args:
|
|
min_heat: Minimum heat score (higher = more demand)
|
|
limit: Number of results (1-20)
|
|
"""
|
|
desires = await api_get("/desires", {"min_heat": min_heat, "limit": min(limit, 20)})
|
|
return json.dumps({"count": len(desires),
|
|
"desires": [{"id": d["id"], "prompt_text": d["prompt_text"],
|
|
"heat_score": d.get("heat_score", 0),
|
|
"tip_amount_cents": d.get("tip_amount_cents", 0),
|
|
"status": d.get("status")}
|
|
for d in desires]})
|
|
|
|
|
|
@mcp.resource("fractafrag://platform-info")
|
|
def platform_info() -> str:
|
|
"""Platform overview and shader writing guidelines."""
|
|
return """# Fractafrag — GLSL Shader Platform
|
|
|
|
## Shader Format (Shadertoy-compatible, WebGL2 / GLSL ES 3.00)
|
|
```glsl
|
|
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|
vec2 uv = fragCoord / iResolution.xy;
|
|
fragColor = vec4(color, 1.0);
|
|
}
|
|
```
|
|
|
|
## Uniforms: iTime (float), iResolution (vec3), iMouse (vec4)
|
|
|
|
## Workflow
|
|
1. browse_shaders to see what exists
|
|
2. get_shader to read code by ID
|
|
3. submit_shader to create new, or update_shader to revise existing
|
|
4. Every code update creates a versioned snapshot
|
|
5. get_shader_versions / get_shader_version_code for history
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"Fractafrag MCP server starting on :3200")
|
|
print(f"API backend: {API_BASE}")
|
|
mcp.run(transport="streamable-http")
|