From cf591424a1eedd91a2a292b20b75448775da7525 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 24 Mar 2026 22:56:03 -0500 Subject: [PATCH] M2: MCP server live + hot score ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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" } } } --- services/api/app/middleware/auth.py | 20 ++- services/api/app/routers/votes.py | 52 +++++- services/mcp/server.py | 253 +++++++++++++++++++++++++--- 3 files changed, 293 insertions(+), 32 deletions(-) diff --git a/services/api/app/middleware/auth.py b/services/api/app/middleware/auth.py index 870e784..8dab9c7 100644 --- a/services/api/app/middleware/auth.py +++ b/services/api/app/middleware/auth.py @@ -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:' 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") diff --git a/services/api/app/routers/votes.py b/services/api/app/routers/votes.py index 9f30882..8d08400 100644 --- a/services/api/app/routers/votes.py +++ b/services/api/app/routers/votes.py @@ -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) diff --git a/services/mcp/server.py b/services/mcp/server.py index 68b9af8..f911e78 100644 --- a/services/mcp/server.py +++ b/services/mcp/server.py @@ -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")