Files
app/server.py
T

563 lines
19 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import re
import time
from pathlib import Path
from typing import Any
import httpx
import uvicorn
from fastmcp import FastMCP
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
from fastmcp.server.dependencies import get_access_token
from prefab_ui.app import PrefabApp
from prefab_ui.components import Badge, Card, Column, Heading, Row, Text
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, Response
DATA = Path(os.getenv("PHAST_DATA_DIR", "/data/phast"))
# The public pages are served by this app too: one source, no copies to drift.
HERE = Path(__file__).resolve().parent
PAGES = {"phast.app": HERE / "index.html", "my.phast.app": HERE / "my" / "index.html"}
DATA.mkdir(parents=True, exist_ok=True)
GITEA = os.getenv("GITEA_URL", "https://teahub.xyz").rstrip("/")
CID = os.environ["GITEA_OAUTH_CLIENT_ID"]
SECRET = os.environ["GITEA_OAUTH_CLIENT_SECRET"]
JWT = os.environ["JWT_SIGNING_KEY"]
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
USERS = {x.strip() for x in os.getenv("ALLOWED_GITEA_USERS", "matcha,chai").split(",") if x.strip()}
RX = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
# Oracle is where providers come from: the capability index's search projection, fetched and cached.
# Phast never holds a copy of the graph, only the one file it needs to find something to connect to.
ORACLE = os.getenv("ORACLE_URL", "https://oracle.teahub.xyz").rstrip("/")
ORACLE_TTL = int(os.getenv("ORACLE_TTL_SECONDS", "3600"))
ORACLE_CACHE = DATA / "_oracle-search.json"
LADDER = ["claimed", "reachable", "tested", "verified", "transacted", "repeated"]
_oracle: dict[str, Any] = {"rows": [], "at": 0.0, "fresh": ""}
def oracle_rows() -> list[dict[str, Any]]:
"""The search index as dicts, refreshed at most every ORACLE_TTL seconds; the last good copy
(memory, then the volume) is used when Oracle can't be reached."""
now = time.time()
if _oracle["rows"] and now - _oracle["at"] < ORACLE_TTL:
return _oracle["rows"]
text, fresh = "", ""
try:
response = httpx.get(ORACLE + "/search.json", timeout=30, headers={"User-Agent": "phast-app/0.2"})
if response.status_code == 200:
text, fresh = response.text, response.headers.get("last-modified", "")
try:
ORACLE_CACHE.write_text(text)
except OSError:
pass
except httpx.HTTPError:
pass
if not text and not _oracle["rows"] and ORACLE_CACHE.exists():
text, fresh = ORACLE_CACHE.read_text(), "cache"
if text:
payload = json.loads(text)
columns = payload["columns"]
_oracle["rows"] = [dict(zip(columns, row)) for row in payload["rows"]]
_oracle["fresh"] = fresh
_oracle["at"] = now
return _oracle["rows"]
def oracle_find(capability_id: str) -> dict[str, Any]:
for row in oracle_rows():
if row["id"] == capability_id:
return row
raise KeyError("unknown capability")
def oracle_search(query: str, limit: int = 10, connectable_only: bool = True) -> list[dict[str, Any]]:
terms = [t for t in re.split(r"[^a-z0-9]+", query.lower()) if t]
if not terms:
return []
hits = []
for row in oracle_rows():
if connectable_only and not row["remote_url"]:
continue
hay = " ".join((row["name"], row["provider"], row["summary"], row["id"])).lower()
if not all(t in hay for t in terms):
continue
name = row["name"].lower()
score = (
LADDER.index(row["evidence_state"]) if row["evidence_state"] in LADDER else 0,
1 if row["remote_url"] else 0,
sum(1 for t in terms if t in name),
1 if "teahub" in row["sources"] else 0,
)
hits.append((score, row))
hits.sort(key=lambda item: item[1]["name"].lower())
hits.sort(key=lambda item: item[0], reverse=True)
return [row for _, row in hits[: max(1, min(limit, 50))]]
class V(IntrospectionTokenVerifier):
async def verify_token(self, token):
access = await super().verify_token(token)
if not access:
return None
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
GITEA + "/login/oauth/userinfo",
headers={"Authorization": "Bearer " + token},
)
if response.status_code != 200:
return None
login = str(response.json().get("preferred_username") or "")
if login not in USERS:
return None
access.claims = {**(access.claims or {}), "login": login}
return access
def auth(base: str, resource: str | None = None) -> OAuthProxy:
verifier = V(
introspection_url=GITEA + "/login/oauth/introspect",
client_id=CID,
client_secret=SECRET,
client_auth_method="client_secret_basic",
required_scopes=["openid"],
)
return OAuthProxy(
upstream_authorization_endpoint=GITEA + "/login/oauth/authorize",
upstream_token_endpoint=GITEA + "/login/oauth/access_token",
upstream_client_id=CID,
upstream_client_secret=SECRET,
token_verifier=verifier,
base_url=base,
resource_base_url=resource or base,
valid_scopes=["openid", "profile", "email"],
forward_pkce=True,
forward_resource=False,
token_endpoint_auth_method="client_secret_basic",
jwt_signing_key=JWT,
require_authorization_consent="remember",
enable_cimd=True,
)
def actor() -> str:
access = get_access_token()
login = str(((access.claims if access else {}) or {}).get("login") or "")
if login not in USERS:
raise RuntimeError("actor not enabled")
return login
def slug(value: str) -> str:
value = value.strip().lower()
if not RX.fullmatch(value) or value in {"my", "www", "mcp"}:
raise ValueError("invalid slug")
return value
def path(value: str) -> Path:
return DATA / slug(value) / "phast.json"
def read(value: str) -> dict[str, Any]:
p = path(value)
if not p.exists():
raise KeyError("unknown Phast app")
return json.loads(p.read_text())
def write(value: str, data: dict[str, Any]) -> dict[str, Any]:
p = path(value)
p.parent.mkdir(parents=True, exist_ok=True)
try:
p.parent.chmod(0o700)
except OSError:
pass
tmp = p.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
tmp.chmod(0o600)
tmp.replace(p)
try:
p.chmod(0o600)
except OSError:
pass
return data
def mine(value: str) -> dict[str, Any]:
data = read(value)
if data.get("owner") != actor():
raise PermissionError("not your Phast app")
return data
def create(value: str, name: str = "") -> dict[str, Any]:
value = slug(value)
if path(value).exists():
raise ValueError("already exists")
# Provision auth before durable state so a failed OAuth mutation cannot leave
# a half-created personal MCP behind.
return write(
value,
{
"schema_version": 1,
"kind": "phast",
"slug": value,
"name": name.strip() or value,
"owner": actor(),
"host": value + ".phast.app",
"surfaces": {"human": "/", "mcp": "/mcp"},
"tools": {"installed": []},
"connections": [],
"prefab": {"version": 1, "home": {"mode": "grid", "edit": True, "widgets": []}},
},
)
def prefab(data: dict[str, Any]) -> PrefabApp:
widgets = data["prefab"]["home"]["widgets"]
with PrefabApp(title=data["name"], css_class="max-w-4xl mx-auto p-6", mode="light") as app:
with Column(gap=5):
with Row(justify="between", align="center"):
Heading(data["name"], level=1)
Badge(str(len(data["tools"]["installed"])) + " tools", variant="secondary")
Text("https://" + data["host"] + "/mcp", code=True)
for tool in data["tools"]["installed"]:
with Card(css_class="p-5"):
with Row(justify="between", align="center"):
Heading(str(tool["name"]), level=2)
Badge(str(tool.get("evidence_state", "claimed")), variant="secondary")
Text(str(tool.get("provider", "")) + " · " + str(tool.get("remote_url", "")))
if not widgets:
with Card(css_class="p-5"):
Heading("Blank home", level=2)
Text("Add a provider or widget from Chat or my.phast.app.")
for widget in widgets:
with Card(css_class="p-5"):
Heading(str(widget.get("title") or widget["id"]), level=2)
Text(str(widget.get("description", "")))
return app
def human(data: dict[str, Any]) -> str:
widgets = data["prefab"]["home"]["widgets"]
belt = "".join(
"<article><h2>"
+ str(tool["name"])
+ " <small>"
+ str(tool.get("evidence_state", "claimed"))
+ "</small></h2><p>"
+ str(tool.get("provider", ""))
+ "</p><p><code>"
+ str(tool.get("remote_url", ""))
+ "</code></p></article>"
for tool in data["tools"]["installed"]
)
cards = "".join(
"<article><h2>"
+ str(widget.get("title") or widget["id"])
+ "</h2><p>"
+ str(widget.get("description", ""))
+ "</p></article>"
for widget in widgets
) or "<article><h2>Blank home</h2><p>Add a widget from Chat or my.phast.app.</p></article>"
return (
'<!doctype html><meta name="viewport" content="width=device-width">'
+ "<title>"
+ data["name"]
+ " - Phast app</title>"
+ "<style>body{margin:0;background:#f5f3ee;color:#121211;font:14px system-ui}"
+ "main{max-width:1000px;margin:auto;padding:32px}header{display:flex;justify-content:space-between}"
+ "b{font-size:28px}em{color:#ff4d00}h1{font-size:72px;letter-spacing:-5px;margin:80px 0 40px}"
+ "section{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}"
+ "article{border:1px solid #d5d1c8;padding:18px;min-height:150px}p{color:#77746c}"
+ "h2 small{font-size:11px;font-weight:400;color:#ff4d00;text-transform:uppercase;letter-spacing:.08em;margin-left:8px}"
+ "h3{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#77746c;margin:32px 0 12px}"
+ "code{font-size:12px;word-break:break-all}"
+ "@media(max-width:700px){section{grid-template-columns:1fr}}</style>"
+ "<main><header><b>Phast app<em>.</em></b><code>"
+ data["host"]
+ "/mcp</code></header><h1>"
+ data["name"]
+ "</h1><h3>Tool belt · "
+ str(len(data["tools"]["installed"]))
+ "</h3><section>"
+ (belt or "<article><h2>Empty belt</h2><p>Ask your agent: search providers, then install one.</p></article>")
+ "</section><h3>Home</h3><section>"
+ cards
+ "</section></main>"
)
m = FastMCP(
"My Phast app",
version="0.2.0",
instructions="Create and manage portable Phast app MCP tool belts and Prefab homes.",
auth=auth("https://my.phast.app"),
mask_error_details=True,
)
@m.tool()
def phast_status() -> dict:
return {"ok": True, "actor": actor(), "version": "0.2.1", "oracle": ORACLE,
"oracle_capabilities": len(_oracle["rows"]), "oracle_fresh": _oracle["fresh"]}
@m.tool()
def phasts_list() -> dict:
current = actor()
rows = []
for p in DATA.glob("*/phast.json"):
try:
data = json.loads(p.read_text())
except Exception:
continue
if data.get("owner") == current:
rows.append(data)
return {"phasts": sorted(rows, key=lambda item: item["slug"])}
@m.tool()
def phasts_get(slug: str) -> dict:
return mine(slug)
@m.tool()
def phasts_create(slug: str, name: str = "") -> dict:
data = create(slug, name)
return {
"phast": data,
"human_url": "https://" + data["host"] + "/",
"mcp_url": "https://" + data["host"] + "/mcp",
}
@m.tool()
def phasts_update(slug: str, name: str) -> dict:
data = mine(slug)
clean = name.strip()
if not clean:
raise ValueError("name is required")
data["name"] = clean
return write(slug, data)
@m.tool()
def providers_search(query: str, limit: int = 10, connectable_only: bool = True) -> dict:
"""Find providers in Oracle, the capability index (oracle.teahub.xyz). Results carry the
evidence state on Oracle's ladder (claimed < reachable < tested < verified < transacted < repeated);
a registry listing is only a claim. connectable_only keeps results with a remote MCP endpoint."""
actor()
rows = oracle_search(query, limit, connectable_only)
return {"query": query, "source": ORACLE, "count": len(rows), "results": rows}
@m.tool()
def tools_list(slug: str) -> dict:
return {"tools": list(mine(slug)["tools"]["installed"])}
@m.tool()
def tools_install(slug: str, capability_id: str) -> dict:
"""Put an Oracle capability on this Phast app's belt. The belt holds a reference to the
capability and its endpoint, never a credential; connecting through it is the next step."""
data = mine(slug)
installed = data["tools"]["installed"]
if any(tool["id"] == capability_id for tool in installed):
raise ValueError("already on the belt")
row = oracle_find(capability_id)
if not row["remote_url"]:
raise ValueError("no remote endpoint to connect to")
tool = {
"id": row["id"], "name": row["name"], "provider": row["provider"],
"remote_url": row["remote_url"], "remote_type": row["remote_type"],
"evidence_state": row["evidence_state"], "sources": row["sources"],
"source": "oracle", "installed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
installed.append(tool)
write(slug, data)
return tool
@m.tool()
def tools_remove(slug: str, capability_id: str) -> dict:
data = mine(slug)
installed = data["tools"]["installed"]
kept = [tool for tool in installed if tool["id"] != capability_id]
if len(kept) == len(installed):
raise KeyError("not on the belt")
data["tools"]["installed"] = kept
write(slug, data)
return {"removed": capability_id}
@m.tool()
def widgets_list(slug: str) -> dict:
return {"widgets": list(mine(slug)["prefab"]["home"]["widgets"])}
@m.tool()
def widgets_add(slug: str, widget_id: str, title: str, description: str = "") -> dict:
data = mine(slug)
widgets = data["prefab"]["home"]["widgets"]
if any(widget["id"] == widget_id for widget in widgets):
raise ValueError("widget exists")
widget = {"id": widget_id, "title": title, "kind": "prefab", "description": description}
widgets.append(widget)
write(slug, data)
return widget
@m.tool()
def widgets_update(slug: str, widget_id: str, title: str = "", description: str = "") -> dict:
data = mine(slug)
for widget in data["prefab"]["home"]["widgets"]:
if widget["id"] == widget_id:
if title.strip():
widget["title"] = title.strip()
if description:
widget["description"] = description
write(slug, data)
return widget
raise KeyError("unknown widget")
@m.tool()
def widgets_remove(slug: str, widget_id: str) -> dict:
data = mine(slug)
widgets = data["prefab"]["home"]["widgets"]
kept = [widget for widget in widgets if widget["id"] != widget_id]
if len(kept) == len(widgets):
raise KeyError("unknown widget")
data["prefab"]["home"]["widgets"] = kept
write(slug, data)
return {"removed": widget_id}
@m.tool(app=True)
def phast_preview(slug: str) -> PrefabApp:
return prefab(mine(slug))
mgmt = m.http_app(path="/mcp", transport="streamable-http", stateless_http=True)
cache: dict[str, Any] = {}
started: dict[str, Any] = {}
locks: dict[str, asyncio.Lock] = {}
def inst(value: str):
value = slug(value)
if value in cache:
return cache[value]
data = read(value)
personal = FastMCP(
data["name"],
version="0.2.0",
instructions="Portable personal Phast app MCP.",
auth=auth("https://my.phast.app", "https://" + data["host"]),
mask_error_details=True,
)
@personal.tool()
def phast_status() -> dict:
owned = mine(value)
return {
"ok": True,
"slug": value,
"owner": owned["owner"],
"tools": len(owned["tools"]["installed"]),
"widgets": len(owned["prefab"]["home"]["widgets"]),
}
@personal.tool()
def phast_manifest() -> dict:
return mine(value)
@personal.tool(app=True)
def home_app() -> PrefabApp:
return prefab(mine(value))
cache[value] = personal.http_app(path="/mcp", transport="streamable-http", stateless_http=True)
return cache[value]
async def ready_inst(value: str):
value = slug(value)
app = inst(value)
if value in started:
return app
lock = locks.setdefault(value, asyncio.Lock())
async with lock:
if value not in started:
cm = app.router.lifespan_context(app)
await cm.__aenter__()
started[value] = cm
return app
class Router:
async def __call__(self, scope, receive, send):
if scope.get("type") == "lifespan":
try:
await mgmt(scope, receive, send)
finally:
for cm in reversed(list(started.values())):
try:
await cm.__aexit__(None, None, None)
except Exception:
pass
return
host = dict(scope.get("headers", [])).get(b"host", b"").decode().split(":")[0].lower()
request_path = scope.get("path", "/")
method = scope.get("method", "GET")
if request_path == "/healthz":
await JSONResponse({"ok": True, "service": "phast", "source_sha": os.getenv("SOURCE_SHA", "local")})(scope, receive, send)
return
page = PAGES.get(host)
if page and request_path == "/" and method in {"GET", "HEAD"}:
await FileResponse(page, media_type="text/html", headers={"Cache-Control": "public, max-age=60"})(scope, receive, send)
return
if host == "phast.app":
await JSONResponse({"error": "not found"}, status_code=404)(scope, receive, send)
return
if host == "my.phast.app":
await mgmt(scope, receive, send)
return
if host.endswith(".phast.app"):
value = host[:-10]
try:
data = read(value)
except Exception:
await JSONResponse({"error": "unknown Phast app"}, status_code=404)(scope, receive, send)
return
if request_path == "/" and method in {"GET", "HEAD"}:
body = human(data)
if method == "HEAD":
await Response(content=b"", media_type="text/html", status_code=200)(scope, receive, send)
else:
await HTMLResponse(body)(scope, receive, send)
return
personal = await ready_inst(value)
await personal(scope, receive, send)
return
await JSONResponse({"error": "unknown host"}, status_code=404)(scope, receive, send)
app = Router()
if __name__ == "__main__":
uvicorn.run(app, host=os.getenv("PHAST_HOST", "::"), port=int(os.getenv("PORT", "8081")))