fractafrag/services/mcp/server.py
John Lightner cf591424a1 M2: MCP server live + hot score ranking
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"
    }
  }
}
2026-03-24 22:56:03 -05:00

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")