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:
parent
c9967a17a0
commit
cf591424a1
3 changed files with 293 additions and 32 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue