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"
    }
  }
}
This commit is contained in:
John Lightner 2026-03-24 22:56:03 -05:00
parent c9967a17a0
commit cf591424a1
3 changed files with 293 additions and 32 deletions

View file

@ -79,11 +79,27 @@ async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""Require authentication. Returns the current user."""
"""Require authentication. Returns the current user.
Supports:
- JWT Bearer tokens (normal user auth)
- Internal service token: 'Bearer internal:<system-jwt>' from MCP/worker
"""
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
payload = decode_token(credentials.credentials)
token = credentials.credentials
# Internal service auth — MCP server and workers use this to act as the system account
if token.startswith("internal:"):
from app.models.models import SYSTEM_USER_ID
result = await db.execute(select(User).where(User.id == SYSTEM_USER_ID))
user = result.scalar_one_or_none()
if user:
return user
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="System user not found")
payload = decode_token(token)
if payload.get("type") == "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot use refresh token for API access")

View file

@ -1,9 +1,11 @@
"""Votes & engagement router."""
"""Votes & engagement router with hot score calculation."""
import math
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, func
from app.database import get_db
from app.models import User, Shader, Vote, EngagementEvent
@ -13,6 +15,37 @@ from app.middleware.auth import get_current_user, get_optional_user
router = APIRouter()
def hot_score(upvotes: int, downvotes: int, age_hours: float) -> float:
"""Wilson score lower bound with time decay.
Balances confidence (more votes = more certain) with recency
(newer shaders get a boost that decays over ~48 hours).
"""
n = upvotes + downvotes
if n == 0:
return 0.0
z = 1.96 # 95% confidence
p = upvotes / n
wilson = (p + z * z / (2 * n) - z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))) / (1 + z * z / n)
decay = 1.0 / (1.0 + age_hours / 48.0)
return wilson * decay
async def recalculate_score(db: AsyncSession, shader: Shader):
"""Recalculate and update a shader's hot score based on current votes."""
up_result = await db.execute(
select(func.count()).select_from(Vote).where(Vote.shader_id == shader.id, Vote.value == 1)
)
down_result = await db.execute(
select(func.count()).select_from(Vote).where(Vote.shader_id == shader.id, Vote.value == -1)
)
upvotes = up_result.scalar() or 0
downvotes = down_result.scalar() or 0
age_hours = (datetime.now(timezone.utc) - shader.created_at.replace(tzinfo=timezone.utc)).total_seconds() / 3600
shader.score = hot_score(upvotes, downvotes, age_hours)
@router.post("/shaders/{shader_id}/vote", status_code=status.HTTP_200_OK)
async def vote_shader(
shader_id: UUID,
@ -20,12 +53,10 @@ async def vote_shader(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# Verify shader exists
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
# Upsert vote
existing = (await db.execute(
select(Vote).where(Vote.user_id == user.id, Vote.shader_id == shader_id)
)).scalar_one_or_none()
@ -35,8 +66,10 @@ async def vote_shader(
else:
db.add(Vote(user_id=user.id, shader_id=shader_id, value=body.value))
# TODO: Recalculate hot score (Track F)
return {"status": "ok", "value": body.value}
await db.flush()
await recalculate_score(db, shader)
return {"status": "ok", "value": body.value, "new_score": round(shader.score, 4)}
@router.delete("/shaders/{shader_id}/vote", status_code=status.HTTP_204_NO_CONTENT)
@ -45,13 +78,18 @@ async def remove_vote(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
existing = (await db.execute(
select(Vote).where(Vote.user_id == user.id, Vote.shader_id == shader_id)
)).scalar_one_or_none()
if existing:
await db.delete(existing)
# TODO: Recalculate hot score (Track F)
await db.flush()
await recalculate_score(db, shader)
@router.post("/shaders/{shader_id}/replay", status_code=status.HTTP_204_NO_CONTENT)

View file

@ -1,33 +1,240 @@
"""Fractafrag MCP Server — stub entrypoint.
"""
Fractafrag MCP Server AI agent interface to the shader platform.
Full implementation in Track E.
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
from http.server import HTTPServer, BaseHTTPRequestHandler
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,
)
class MCPHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok", "service": "mcp"}).encode())
else:
self.send_response(501)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "MCP server coming in M2"}).encode())
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()
def do_POST(self):
self.send_response(501)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "MCP server coming in M2"}).encode())
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__":
server = HTTPServer(("0.0.0.0", 3200), MCPHandler)
print("MCP server stub listening on :3200")
server.serve_forever()
print(f"Fractafrag MCP server starting on :3200")
print(f"API backend: {API_BASE}")
mcp.run(transport="streamable-http")