Sync with canonical matcha/phast@69bbbab and run on Fly as phast-app
The 2026-10-14 port took tea-mcp/src/phast, an older copy (light design, no OAuth). This merges matcha/phast main with its history: the black/orange phast.app and my.phast.app pages, and the Teahub-OAuth MCP server. On top, for prod: - server.py serves phast.app / and my.phast.app / from this repo (no copies), and /healthz - Dockerfile + start.sh: volume at /data, app runs unprivileged - fly.toml: phast-app, volume phast_data, GITEA_URL=https://teahub.xyz
This commit is contained in:
commit
cbd4a28e75
13 files changed
+612
-280
No files matched your search
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
|
||||
Build and maintain Phast app as a deliberately small Python + Prefab + MCP application platform.
|
||||
|
||||
## Product model
|
||||
|
||||
- `my.phast.app` is the owner control plane.
|
||||
- Each `app.phast.app` is independently useful and independently connectable.
|
||||
- `/` is the human Prefab UI by default.
|
||||
- `/mcp` is the agent interface by default.
|
||||
- A Prephab is a cloneable Python + Prefab + MCP application shape.
|
||||
|
||||
## Working rules
|
||||
|
||||
- Prefer boring, explicit Python over framework sprawl.
|
||||
- Keep one source of truth for app state and capabilities.
|
||||
- Do not fake auth or security; mark seams explicitly.
|
||||
- No delete path until it is deliberately designed.
|
||||
- Commit small coherent changes to `main` and leave the system simpler.
|
||||
+4
-4
@@ -3,10 +3,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY server.py registry.py index.html phast.json ./
|
||||
COPY server.py registry.py index.html phast.json start.sh ./
|
||||
COPY my/index.html my/index.html
|
||||
ARG SOURCE_SHA=unknown
|
||||
ENV SOURCE_SHA=$SOURCE_SHA
|
||||
RUN useradd --create-home --uid 10001 phast
|
||||
USER phast
|
||||
RUN useradd --create-home --uid 10001 phast && chmod +x start.sh
|
||||
EXPOSE 8080
|
||||
CMD ["python", "server.py"]
|
||||
CMD ["./start.sh"]
|
||||
@@ -1,42 +1,73 @@
|
||||
# Phast runtime
|
||||
# Phast app
|
||||
|
||||
This directory is the canonical source for the existing `phast-app` Fly app,
|
||||
serving `phast.app` and `my.phast.app`. It remains one Python/FastMCP runtime.
|
||||
Canonical source of truth for Phast app.
|
||||
|
||||
## App convention
|
||||
> **Connect your tools once. Bring your tool belt anywhere.**
|
||||
|
||||
`phast.json` is the minimal Prephab manifest. Schema version 1 requires a
|
||||
stable slug and name, an HTTPS origin, Python as the runtime, `/` as the default
|
||||
human route, `/mcp` as the default MCP route, and Prephab shape version 1.
|
||||
Registry tools return this same shape.
|
||||
Phast app gives a person one portable MCP plus a programmable Prefab home. Connect `my.phast.app/mcp` to an agent to create and manage Phast apps. A created Phast app has two matching surfaces:
|
||||
|
||||
`my.phast.app` renders its human surface with the installed Prefab UI package.
|
||||
Its MCP exposes `apps_list`, `apps_get`, `apps_create`, and `apps_update`, plus
|
||||
bounded status and Prefab-app tools. There is intentionally no delete.
|
||||
- `user.phast.app` — human / Prefab home
|
||||
- `user.phast.app/mcp` — the user's portable MCP tool belt
|
||||
|
||||
## Production blockers
|
||||
## V0 contract
|
||||
|
||||
The current Fly app has no volume and Phast has no accepted owner-auth pattern.
|
||||
The registry therefore refuses to treat the image filesystem as durable, and
|
||||
create/update always fail before writing. Do not deploy this as an enabled
|
||||
control plane until both seams are closed:
|
||||
`phast.json` is deliberately boring. It is mostly:
|
||||
|
||||
1. create and attach one Fly volume at `/data`, then set
|
||||
`PHAST_DATA_DIR=/data`; the runtime verifies that the directory is a mount;
|
||||
2. reuse an accepted FastMCP owner identity provider and replace the explicit
|
||||
mutation guard with caller identity/authorization checks.
|
||||
1. the MCP tool list and provider connections;
|
||||
2. references to delegated credential authority (Persimmons, never raw secrets);
|
||||
3. Prefab home/widget settings.
|
||||
|
||||
Do not substitute a shared secret in the UI or an environment flag for owner
|
||||
authentication. Once both prerequisites exist, exercise list/get/create/update
|
||||
through `my.phast.app/mcp`, verify persistence across a Machine replacement,
|
||||
then deploy through the existing Phast launch path.
|
||||
The browser and chat are two editors for the same declaration.
|
||||
|
||||
## Local validation
|
||||
### Browser flow
|
||||
|
||||
Run `python -m unittest tests.test_phast`. Tests use a temporary SQLite file;
|
||||
they do not weaken the production mount or authentication guards.
|
||||
Home → Edit Home → Add Widget → provider search → connect if necessary → place/configure widget.
|
||||
|
||||
## On Teahub prod (2026-10-14)
|
||||
Ported from the old factory (`tea-mcp/src/phast`, plus `phastacp` as `acp/`) as it was. Phast is a client of Teahub:
|
||||
this org owns the code, the site (`phast/site`) and phast.app. The Fly app named in `fly.toml` (`phast-app`) does not
|
||||
exist yet; the production blockers above still stand (volume at /data, owner auth via Gitea OAuth).
|
||||
### Agent flow
|
||||
|
||||
Connect once to `my.phast.app/mcp` → create/manage `user.phast.app` → search providers → add tools/widgets → stream/taste-test Prefab in chat → persist accepted UI to the same `phast.json`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Phast app composes.** It owns the portable MCP/tool belt and Prefab composition.
|
||||
- **Oracle discovers.** Provider/MCP/OAuth discovery comes from the capability graph, not a hard-coded Phast app directory.
|
||||
- **Persimmons holds secrets.** Phast app stores connection/authority references, not secret values.
|
||||
- **Factory builds/operates.** Provisioning uses bounded deterministic infrastructure routes.
|
||||
- **Clients are replaceable.** ChatGPT, Claude, Codex, or another MCP client should be able to use the same Phast app without recollecting the user's tools.
|
||||
|
||||
V0 is intentionally not arbitrary app generation. Prove the portable-tool-belt + editable-home loop first.
|
||||
|
||||
## Naming and copy
|
||||
|
||||
Use **Phast app** when naming the product or one of its instances; use
|
||||
**Phast apps** for the plural. Write “your Phast app,” “create a Phast app,”
|
||||
and “My Phast app” for the management connector. Existing all-caps display
|
||||
styles may use “PHAST APP.” Apply this to page titles, metadata, navigation,
|
||||
buttons, examples, generated homes, MCP display text, and documentation.
|
||||
|
||||
Keep deliberate substitutions for “fast”: “make something phast,”
|
||||
“built phast,” and “make MCP apps phast.” Do not rename literal domains,
|
||||
URLs, `phast.json`, environment variables, CSS classes, tool names, or
|
||||
schema keys/values as part of a copy edit. Preserve user-chosen app names.
|
||||
|
||||
The public copy lives in `index.html` (phast.app), `my/index.html`
|
||||
(my.phast.app), and `server.py` (generated personal homes and MCP display
|
||||
text). `phast.json` supplies the management display name. Review these
|
||||
surfaces together when changing product naming.
|
||||
|
||||
Chair review: the standalone “PHAST.” wordmark and the “My Phast” connector
|
||||
label could be separate brand treatments. This change proposes “PHAST APP.”
|
||||
and “My Phast app” consistently; the chair should confirm those treatments.
|
||||
The phrase “your Phast” names an app instance, so it becomes “your Phast app.”
|
||||
|
||||
## Running
|
||||
|
||||
Prod since 2026-10-14: Fly app `phast-app` (this `fly.toml`), one machine, volume `phast_data` at `/data`
|
||||
(`PHAST_DATA_DIR=/data/phast`, `FASTMCP_HOME=/data/fastmcp`). It serves phast.app, my.phast.app and
|
||||
`*.phast.app`; the two pages are this repo's `index.html` and `my/index.html`, not copies. Sign-in is
|
||||
Teahub OAuth (app "Phast app" owned by `teahubxyz`, redirect `https://my.phast.app/auth/callback`);
|
||||
`GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET` and `JWT_SIGNING_KEY` exist only as Fly secrets.
|
||||
`ALLOWED_GITEA_USERS` says who may use it.
|
||||
|
||||
Deploy: `fly deploy --build-arg SOURCE_SHA=$(git rev-parse HEAD)`; `GET /healthz` reports the SHA.
|
||||
History before 2026-10-14 is `matcha/phast` from the old factory, merged in.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"""Phast application runtime."""
|
||||
"""Phast app runtime."""
|
||||
@@ -4,20 +4,31 @@ primary_region = "iad"
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
GITEA_URL = "https://teahub.xyz"
|
||||
PHAST_DATA_DIR = "/data/phast"
|
||||
FASTMCP_HOME = "/data/fastmcp"
|
||||
ALLOWED_GITEA_USERS = "matcha,chai"
|
||||
PORT = "8080"
|
||||
PHAST_HOST = "0.0.0.0" # asyncio binds :: as IPv6-only; the Fly proxy arrives over IPv4
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
auto_stop_machines = "off"
|
||||
min_machines_running = 1
|
||||
|
||||
[[http_service.checks]]
|
||||
grace_period = "15s"
|
||||
interval = "15s"
|
||||
grace_period = "20s"
|
||||
interval = "30s"
|
||||
method = "GET"
|
||||
path = "/healthz"
|
||||
timeout = "5s"
|
||||
|
||||
[mounts]
|
||||
source = "phast_data"
|
||||
destination = "/data"
|
||||
|
||||
[[vm]]
|
||||
memory = "512mb"
|
||||
cpu_kind = "shared"
|
||||
|
||||
+41
-68
@@ -1,73 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f5f3ee" />
|
||||
<meta name="description" content="Phast — connect your agent, compose what you need, launch when it's worth keeping." />
|
||||
<title>phast</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--paper:#f5f3ee;--ink:#121211;--muted:#77746c;--line:rgba(18,18,17,.15);--signal:#ff4d00;--pad:clamp(22px,3vw,48px)}
|
||||
*{box-sizing:border-box}html,body{min-height:100%}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);font-family:"Manrope",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
|
||||
button,a{font:inherit}a{color:inherit;text-decoration:none}
|
||||
.shell{min-height:100svh;padding:var(--pad);display:grid;grid-template-rows:auto 1fr auto;gap:28px;overflow:hidden}
|
||||
.masthead,.footer{display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.brand{position:relative;display:inline-flex;align-items:baseline;font-size:clamp(27px,2.2vw,38px);font-weight:700;letter-spacing:-.075em;line-height:1}
|
||||
.brand::after{content:"";position:absolute;left:0;right:0;bottom:-8px;height:2px;background:var(--ink);transform:scaleX(0);transform-origin:right;transition:transform 220ms cubic-bezier(.2,.8,.2,1)}
|
||||
.brand:hover::after{transform:scaleX(1);transform-origin:left}.brand-dot{color:var(--signal)}
|
||||
.tiny-link{border:0;padding:0;margin:0;background:transparent;color:inherit;cursor:pointer;display:grid;justify-items:end;gap:2px}
|
||||
.tiny-label{color:var(--muted);font-size:10px;letter-spacing:.09em;text-transform:uppercase}
|
||||
.tiny-value{font-family:"DM Mono",ui-monospace,monospace;font-size:12px;border-bottom:1px solid var(--line);transition:border-color 160ms ease}
|
||||
.tiny-link:hover .tiny-value{border-color:var(--ink)}
|
||||
.hero{align-self:center;width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:clamp(44px,8vw,130px)}
|
||||
.hero-copy{min-width:0}.eyebrow{margin:0 0 clamp(18px,2vw,28px);font-family:"DM Mono",ui-monospace,monospace;font-size:clamp(10px,.8vw,12px);letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}
|
||||
h1{margin:0;max-width:8.5ch;font-size:clamp(62px,10.5vw,176px);font-weight:500;line-height:.82;letter-spacing:-.087em}
|
||||
.speed-word{position:relative;display:inline-block;cursor:default}
|
||||
.speed-word::after{content:"";position:absolute;height:.075em;left:.08em;right:-.16em;bottom:-.085em;background:var(--signal);transform:scaleX(.16);transform-origin:left;transition:transform 280ms cubic-bezier(.2,.85,.2,1)}
|
||||
.speed-word:hover::after{transform:scaleX(1)}
|
||||
.action-stack{width:min(100%,360px);padding-bottom:.45vw}
|
||||
.primary-action{min-height:74px;padding:0 22px 0 24px;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:space-between;gap:26px;border:1px solid var(--ink);font-size:16px;font-weight:600;letter-spacing:-.02em;transition:transform 160ms ease,background 160ms ease,color 160ms ease}
|
||||
.primary-action:hover{background:var(--signal);border-color:var(--signal);transform:translateY(-3px)}.primary-action:active{transform:translateY(0)}
|
||||
.arrow{font-size:24px;line-height:1;transition:transform 160ms ease}.primary-action:hover .arrow{transform:translate(3px,-3px)}
|
||||
.microcopy{margin:14px 0 0;max-width:32ch;color:var(--muted);font-size:12px;line-height:1.5}
|
||||
.footer{font-family:"DM Mono",ui-monospace,monospace;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||||
.status{display:inline-flex;align-items:center;gap:8px}.status i{width:6px;height:6px;border-radius:50%;background:var(--signal);box-shadow:0 0 0 0 rgba(255,77,0,.35);animation:pulse 2.4s ease-out infinite}.manifesto{color:var(--ink)}
|
||||
.toast{position:fixed;top:var(--pad);left:50%;transform:translate(-50%,-12px);opacity:0;pointer-events:none;background:var(--ink);color:var(--paper);padding:9px 12px;font-family:"DM Mono",ui-monospace,monospace;font-size:11px;text-transform:uppercase;letter-spacing:.06em;transition:opacity 140ms ease,transform 140ms ease}
|
||||
.toast.show{opacity:1;transform:translate(-50%,0)}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(255,77,0,.34)}55%,100%{box-shadow:0 0 0 9px rgba(255,77,0,0)}}
|
||||
@media(max-width:800px){.shell{min-height:100dvh}.hero{grid-template-columns:1fr;align-items:start;gap:clamp(48px,10vh,88px)}h1{font-size:clamp(64px,18vw,112px);line-height:.84}.action-stack{width:100%}.primary-action{width:100%}.microcopy{max-width:38ch}}
|
||||
@media(max-width:520px){.tiny-label{display:none}.tiny-value{font-size:10px}.footer{align-items:flex-end}.manifesto{text-align:right}}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
|
||||
</style>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#050505">
|
||||
<meta name="description" content="Phast app — make MCP apps phast.">
|
||||
<title>Phast app</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bangers&family=DM+Mono:wght@400;500&family=Inter:wght@500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#050505;--panel:#0d0d0d;--ink:#f5f2e9;--muted:#8a8a84;--orange:#ff5a00;--line:#272727;--pad:clamp(22px,4vw,56px)}
|
||||
*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;overflow-x:hidden}a,button{color:inherit;font:inherit}a{text-decoration:none}.shell{min-height:100svh;padding:var(--pad);display:grid;grid-template-rows:auto 1fr auto;position:relative;isolation:isolate}.shell:before{content:"";position:absolute;inset:0;z-index:-2;background:repeating-linear-gradient(115deg,transparent 0 92px,rgba(255,90,0,.05) 92px 94px,transparent 94px 180px)}.shell:after{content:"";position:absolute;width:58vw;height:12px;background:var(--orange);right:-14vw;top:23%;transform:skewX(-28deg) rotate(-8deg);box-shadow:0 24px 0 rgba(255,90,0,.42),0 48px 0 rgba(255,90,0,.18);z-index:-1}
|
||||
.top,.foot{display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{font-family:Bangers,sans-serif;font-size:32px;letter-spacing:.05em;color:var(--orange)}.connector{font:11px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);background:none;border:0;border-bottom:1px solid #333;padding:0 0 5px;cursor:pointer}.connector:hover{color:var(--orange);border-color:var(--orange)}
|
||||
.hero{align-self:center;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(260px,.55fr);gap:clamp(34px,7vw,110px);align-items:end;padding:7vh 0}.kicker,.meta,.foot{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.12em}.kicker{color:var(--orange);margin:0 0 16px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;margin:0;transform:skewY(-2deg);text-shadow:6px 6px 0 #1b1b1b}.make-line{display:block;white-space:nowrap;color:var(--orange);font-size:clamp(54px,6.5vw,102px);line-height:.8;letter-spacing:.015em}.phast-line{display:block;color:var(--ink);font-size:clamp(112px,16vw,250px);line-height:.72;letter-spacing:.025em;margin-top:.1em}.copy{border-top:1px solid var(--line);padding-top:18px}.copy p{font-size:clamp(16px,1.4vw,21px);line-height:1.45;margin:0 0 24px;max-width:33ch}.copy strong{color:var(--orange)}.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--orange);color:#080808;padding:18px 20px;border:0;font-weight:900;text-transform:uppercase;letter-spacing:.03em;transform:skewX(-7deg);transition:.15s}.cta>*{transform:skewX(7deg)}.cta:hover{transform:skewX(-7deg) translate(5px,-3px);box-shadow:-8px 8px 0 #fff}.sequence{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.step{background:var(--panel);padding:15px}.step b{display:block;font-family:Bangers,sans-serif;color:var(--orange);font-size:25px;letter-spacing:.04em}.step span{font:10px "DM Mono",monospace;color:var(--muted);text-transform:uppercase}.foot{color:#666}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;left:50%;bottom:28px;transform:translate(-50%,10px);opacity:0;background:var(--orange);color:#000;padding:9px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;transition:.15s}.toast.show{opacity:1;transform:translate(-50%,0)}
|
||||
@media(max-width:820px){.hero{grid-template-columns:1fr;align-items:start}.make-line{font-size:clamp(46px,11vw,82px)}.phast-line{font-size:clamp(92px,22vw,154px)}.copy{max-width:560px}.sequence{grid-template-columns:1fr 1fr}.shell:after{top:18%;width:90vw}}@media(max-width:480px){.make-line{white-space:normal}.sequence{grid-template-columns:1fr}.connector{font-size:9px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell" aria-labelledby="phast-title">
|
||||
<header class="masthead" aria-label="Phast">
|
||||
<a class="brand" href="/" aria-label="Phast home">phast<span class="brand-dot">.</span></a>
|
||||
<button class="tiny-link" type="button" id="copyConnector" aria-label="Copy your Phast connector address">
|
||||
<span class="tiny-label">your connector</span>
|
||||
<span class="tiny-value">my.phast.app/mcp</span>
|
||||
</button>
|
||||
</header>
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow" aria-hidden="true">connect → compose → launch</p>
|
||||
<h1 id="phast-title">make something.<br><span class="speed-word">phast.</span></h1>
|
||||
</div>
|
||||
<div class="action-stack">
|
||||
<a class="primary-action" href="https://my.phast.app" id="connectAgent"><span>Connect your agent</span><span class="arrow" aria-hidden="true">↗</span></a>
|
||||
<p class="microcopy">Bring the intelligence. Phast turns it into something you can use.</p>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="footer"><span class="status"><i></i> ready to make</span><span class="manifesto">idea → app</span></footer>
|
||||
</main>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite">copied</div>
|
||||
<script>
|
||||
const connector='my.phast.app/mcp';const copyButton=document.querySelector('#copyConnector');const toast=document.querySelector('#toast');let toastTimer;function showToast(message='copied'){toast.textContent=message;toast.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>toast.classList.remove('show'),1200)}copyButton?.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(connector);showToast('connector copied')}catch{showToast(connector)}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<main class="shell">
|
||||
<header class="top"><a class="brand" href="/">phast.app</a><button class="connector" id="copy">my.phast.app/mcp ↗</button></header>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="kicker">MCP APPS // ZERO TO USEFUL</p>
|
||||
<h1><span class="make-line">MAKE SOMETHING</span><span class="phast-line">PHAST</span></h1>
|
||||
<div class="sequence">
|
||||
<div class="step"><b>01 CONNECT</b><span>add my.phast.app/mcp</span></div>
|
||||
<div class="step"><b>02 MAKE</b><span>create your Phast app</span></div>
|
||||
<div class="step"><b>03 TUNE</b><span>tools + prefab widgets</span></div>
|
||||
<div class="step"><b>04 RUN</b><span>use it from any agent</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copy">
|
||||
<p><strong>Connect your tools once.</strong><br>Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast app, same tools, same home.</p>
|
||||
<a class="cta" href="https://my.phast.app"><span>BUILD YOUR PHAST APP</span><span>→</span></a>
|
||||
<p class="meta" style="margin-top:14px;color:#666">one connector // many personal MCPs</p>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="foot"><span><b>idea → mcp → app</b></span><span>make it // use it // keep moving</span></footer>
|
||||
</main>
|
||||
<div class="toast" id="toast">MCP COPIED</div>
|
||||
<script>
|
||||
const c=document.querySelector('#copy'),t=document.querySelector('#toast');c.onclick=async()=>{try{await navigator.clipboard.writeText('https://my.phast.app/mcp');t.textContent='MCP COPIED'}catch{t.textContent='my.phast.app/mcp'}t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1100)};
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#050505">
|
||||
<title>my.phast.app — your Phast app</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bangers&family=DM+Mono:wght@400;500&family=Inter:wght@500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#050505;--panel:#0d0d0d;--panel2:#111;--ink:#f7f3e9;--muted:#7d7d78;--orange:#ff5a00;--line:#272727;--pad:clamp(20px,3.5vw,52px)}
|
||||
*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif}button,a,input{font:inherit;color:inherit}a{text-decoration:none}button{cursor:pointer}
|
||||
.page{min-height:100svh;padding:var(--pad);position:relative;overflow:hidden}.page:before{content:"";position:fixed;inset:0;pointer-events:none;background:repeating-linear-gradient(118deg,transparent 0 110px,rgba(255,90,0,.035) 110px 112px,transparent 112px 220px)}.top{position:relative;display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{font-family:Bangers,sans-serif;color:var(--orange);font-size:30px;letter-spacing:.06em}.topnote{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}
|
||||
.hero{position:relative;margin:clamp(70px,10vh,130px) 0 54px;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(300px,.55fr);gap:clamp(32px,7vw,100px);align-items:end}.kicker,.label,.mono{font:10px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.11em}.kicker{color:var(--orange);margin:0 0 10px}.hero h1{font-family:Bangers,sans-serif;font-weight:400;font-size:clamp(78px,12vw,170px);line-height:.75;letter-spacing:.02em;margin:0;color:var(--orange);text-shadow:5px 5px 0 #1c1c1c;transform:skewY(-1deg)}.hero h1 span{color:var(--ink);display:block;font-size:.43em;line-height:.95;margin-top:.2em}.intro{border-top:1px solid var(--line);padding-top:18px}.intro p{font-size:17px;line-height:1.5;margin:0 0 20px;color:#d7d4cc}.intro strong{color:var(--orange)}
|
||||
.connector{background:var(--orange);color:#070707;padding:16px 18px;display:flex;align-items:center;justify-content:space-between;gap:20px;border:0;width:100%;font-weight:900;text-transform:uppercase;transform:skewX(-6deg);transition:.14s}.connector span{transform:skewX(6deg)}.connector:hover{transform:skewX(-6deg) translate(4px,-3px);box-shadow:-7px 7px 0 #fff}.endpoint{margin-top:11px;font:10px "DM Mono",monospace;color:var(--muted);word-break:break-all}
|
||||
.lane{position:relative;border-top:1px solid var(--orange);padding-top:12px}.lanehead{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.lanehead h2{font-family:Bangers,sans-serif;font-size:32px;letter-spacing:.05em;margin:0}.lanehead span{color:var(--orange)}
|
||||
.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line)}.step{background:var(--panel);padding:22px;min-height:200px;position:relative;overflow:hidden}.step:after{content:attr(data-n);position:absolute;right:6px;bottom:-34px;font-family:Bangers,sans-serif;color:#171717;font-size:125px;line-height:1}.step .label{color:var(--orange);position:relative;z-index:1}.step h3{font-family:Bangers,sans-serif;font-size:36px;letter-spacing:.04em;margin:13px 0 8px;position:relative;z-index:1}.step p{color:var(--muted);font-size:12px;line-height:1.55;max-width:30ch;position:relative;z-index:1}.cmd{margin-top:15px;border:1px solid #333;background:#080808;padding:10px 12px;font:11px "DM Mono",monospace;color:#bbb;position:relative;z-index:1}.cmd b{color:var(--orange);font-weight:500}
|
||||
.what{display:grid;grid-template-columns:1.15fr .85fr;gap:1px;background:var(--line);border:1px solid var(--line);margin-top:26px}.card{background:var(--panel2);padding:22px}.card h3{font-family:Bangers,sans-serif;letter-spacing:.04em;font-size:30px;margin:6px 0 10px}.card p{font-size:12px;line-height:1.6;color:var(--muted);max-width:60ch}.belt{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}.pill{border:1px solid #353535;padding:7px 9px;font:9px "DM Mono",monospace;text-transform:uppercase;color:#aaa}.pill.hot{border-color:var(--orange);color:var(--orange)}
|
||||
.foot{display:flex;justify-content:space-between;gap:20px;margin-top:42px;padding-top:13px;border-top:1px solid var(--line);font:9px "DM Mono",monospace;text-transform:uppercase;letter-spacing:.1em;color:#60605d}.foot b{color:var(--orange);font-weight:500}.toast{position:fixed;right:24px;bottom:24px;background:var(--orange);color:#000;padding:10px 12px;font:10px "DM Mono",monospace;text-transform:uppercase;opacity:0;transform:translateY(8px);transition:.14s}.toast.show{opacity:1;transform:none}
|
||||
@media(max-width:850px){.hero{grid-template-columns:1fr}.steps{grid-template-columns:1fr}.step{min-height:150px}.what{grid-template-columns:1fr}.hero h1{font-size:clamp(86px,23vw,140px)}}@media(max-width:500px){.topnote{display:none}.page{padding:20px}.hero{margin-top:70px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="top"><a class="brand" href="https://phast.app">PHAST APP.</a><span class="topnote">MY PHAST APP // CONTROL MCP</span></header>
|
||||
<section class="hero">
|
||||
<div><p class="kicker">START HERE // ONE CONNECTION</p><h1>YOUR MCP.<span>BUILT PHAST.</span></h1></div>
|
||||
<div class="intro"><p>Add <strong>My Phast app</strong> to your agent once. Then create personal MCPs, add tools, stream Prefab, and keep what works.</p><button class="connector" id="copy"><span>COPY YOUR PHAST APP MCP</span><span>↗</span></button><div class="endpoint">https://my.phast.app/mcp</div></div>
|
||||
</section>
|
||||
<section class="lane">
|
||||
<div class="lanehead"><h2>ZERO → PERSONAL MCP</h2><span class="mono">CHAT IS THE SETUP UI</span></div>
|
||||
<div class="steps">
|
||||
<article class="step" data-n="1"><span class="label">01 // CONNECT</span><h3>ADD MY PHAST APP</h3><p>Connect the management MCP in ChatGPT, Claude, Codex, or another MCP client. Authenticate once with Teahub.</p><div class="cmd"><b>MCP</b> my.phast.app/mcp</div></article>
|
||||
<article class="step" data-n="2"><span class="label">02 // CREATE</span><h3>TELL YOUR AGENT</h3><p>No project wizard. Name it in chat. Wildcard DNS, TLS, home, and MCP are already waiting.</p><div class="cmd"><b>YOU</b> create me a Phast app called sunday</div></article>
|
||||
<article class="step" data-n="3"><span class="label">03 // TUNE</span><h3>BUILD THE BELT</h3><p>Add providers, tools, and Prefab widgets. Your agent and your web home edit the same Phast app.</p><div class="cmd"><b>YOU</b> add github + a PR widget</div></article>
|
||||
</div>
|
||||
</section>
|
||||
<section class="what">
|
||||
<div class="card"><span class="label">WHAT YOU GET</span><h3>YOURNAME.PHAST.APP</h3><p>A fast personal web home for the human side of your composition. Widgets are saved views of the same capabilities your agent can use.</p><div class="belt"><span class="pill hot">/ human home</span><span class="pill">Prefab widgets</span><span class="pill">edit by chat</span></div></div>
|
||||
<div class="card"><span class="label">TAKE IT ANYWHERE</span><h3>/MCP</h3><p>Your portable tool belt. Connect <b style="color:var(--orange)">yourname.phast.app/mcp</b> to another agent without recollecting every tool.</p><div class="belt"><span class="pill hot">ChatGPT</span><span class="pill">Claude</span><span class="pill">Codex</span><span class="pill">whatever's next</span></div></div>
|
||||
</section>
|
||||
<footer class="foot"><span><b>connect once // compose continuously</b></span><span>my.phast.app/mcp → *.phast.app/mcp</span></footer>
|
||||
</main>
|
||||
<div class="toast" id="toast">MCP COPIED</div>
|
||||
<script>
|
||||
const b=document.querySelector('#copy'),t=document.querySelector('#toast');b.onclick=async()=>{try{await navigator.clipboard.writeText('https://my.phast.app/mcp');t.textContent='MCP COPIED'}catch{t.textContent='my.phast.app/mcp'}t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1200)};
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -1 +0,0 @@
|
||||
{"repository":"matcha/phast","commit":"830ab44d8ff0a8eb95fbff1bdc76d6680486daca","server_sha256":"1279780a42744d71b974f283d6693ad053b3e50c782836f7c15894352f6345da"}
|
||||
+14
-8
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"kind": "phast.app",
|
||||
"slug": "my-phast",
|
||||
"name": "My Phast",
|
||||
"runtime": "python",
|
||||
"base_url": "https://my.phast.app",
|
||||
"human": {"route": "/"},
|
||||
"mcp": {"route": "/mcp"},
|
||||
"prephab": {"version": 1}
|
||||
"kind": "phast",
|
||||
"slug": "my",
|
||||
"name": "My Phast app",
|
||||
"host": "my.phast.app",
|
||||
"surfaces": {"human": "/", "mcp": "/mcp"},
|
||||
"tools": {
|
||||
"management": ["phasts_list","phasts_get","phasts_create","phasts_update","providers_search","widgets_list","widgets_add","widgets_update","widgets_remove"],
|
||||
"installed": []
|
||||
},
|
||||
"connections": [],
|
||||
"prefab": {
|
||||
"version": 1,
|
||||
"home": {"mode": "grid", "edit": true, "widgets": []}
|
||||
}
|
||||
}
|
||||
+7
-21
@@ -13,29 +13,24 @@ from urllib.parse import urlsplit
|
||||
SCHEMA_VERSION = 1
|
||||
_SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
|
||||
|
||||
class RegistryUnavailable(RuntimeError):
|
||||
"""Raised when the production persistence contract is not satisfied."""
|
||||
|
||||
pass
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _validate_slug(value: str) -> str:
|
||||
value = value.strip()
|
||||
if not _SLUG.fullmatch(value):
|
||||
raise ValueError("slug must be 1-63 lowercase letters, numbers, or hyphens")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_name(value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value or len(value) > 100:
|
||||
raise ValueError("name must be 1-100 characters")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_base_url(value: str) -> str:
|
||||
value = value.strip().rstrip("/")
|
||||
parsed = urlsplit(value)
|
||||
@@ -43,14 +38,12 @@ def _validate_base_url(value: str) -> str:
|
||||
raise ValueError("base_url must be an HTTPS origin without a path")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_route(value: str, field: str) -> str:
|
||||
value = value.strip()
|
||||
if not value.startswith("/") or value.startswith("//") or "?" in value or "#" in value:
|
||||
raise ValueError(f"{field} must be an absolute path without query or fragment")
|
||||
return value
|
||||
|
||||
|
||||
def _manifest(row: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
@@ -66,10 +59,7 @@ def _manifest(row: sqlite3.Row) -> dict:
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
class AppRegistry:
|
||||
"""Small SQLite-backed registry; one connection is used per operation."""
|
||||
|
||||
def __init__(self, database: str | Path):
|
||||
self.database = Path(database)
|
||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -93,8 +83,7 @@ class AppRegistry:
|
||||
def _initialize(self) -> None:
|
||||
with self._connection() as connection:
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
connection.execute("""
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -104,8 +93,7 @@ class AppRegistry:
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
with self._connection() as connection:
|
||||
@@ -127,12 +115,12 @@ class AppRegistry:
|
||||
try:
|
||||
with self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO apps (slug, name, base_url, human_route, mcp_route, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)", (*values, timestamp, timestamp),
|
||||
"INSERT INTO apps (slug, name, base_url, human_route, mcp_route, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(*values, timestamp, timestamp),
|
||||
)
|
||||
except sqlite3.IntegrityError as error:
|
||||
raise ValueError("an app with this slug or base_url already exists") from error
|
||||
return self.get(values[0]) # type: ignore[return-value]
|
||||
return self.get(values[0])
|
||||
|
||||
def update(self, slug: str, *, name: str | None = None, base_url: str | None = None,
|
||||
human_route: str | None = None, mcp_route: str | None = None) -> dict:
|
||||
@@ -157,11 +145,9 @@ class AppRegistry:
|
||||
raise KeyError(f"unknown app: {slug}")
|
||||
except sqlite3.IntegrityError as error:
|
||||
raise ValueError("an app with this base_url already exists") from error
|
||||
return self.get(slug) # type: ignore[return-value]
|
||||
|
||||
return self.get(slug)
|
||||
|
||||
def registry_from_runtime() -> AppRegistry:
|
||||
"""Open production state only when its configured directory is a real mount."""
|
||||
raw = os.environ.get("PHAST_DATA_DIR", "").strip()
|
||||
if not raw:
|
||||
raise RegistryUnavailable("PHAST_DATA_DIR is not configured; durable registry is offline")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
fastmcp==4.0.10
|
||||
prefab-ui==0.20.2
|
||||
mcp==2.2.0
|
||||
httpx==0.28.1
|
||||
uvicorn==0.37.0
|
||||
@@ -1,174 +1,425 @@
|
||||
"""Phast public landing and fail-closed owner control plane."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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, Button, Card, Code, Column, Heading, Row, Separator, Text
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
from prefab_ui.components import Badge, Card, Column, Heading, Row, Text
|
||||
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
|
||||
try:
|
||||
from .registry import AppRegistry, RegistryUnavailable, registry_from_runtime
|
||||
except ImportError: # Docker runs this file directly from /app.
|
||||
from registry import AppRegistry, RegistryUnavailable, registry_from_runtime
|
||||
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])?$")
|
||||
|
||||
OWNER_HOST = "my.phast.app"
|
||||
AUTH_BLOCKER = "Owner authentication is not configured; app mutations are disabled."
|
||||
|
||||
try:
|
||||
registry: AppRegistry | None = registry_from_runtime()
|
||||
registry_blocker: str | None = None
|
||||
except RegistryUnavailable as error:
|
||||
registry = None
|
||||
registry_blocker = str(error)
|
||||
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
|
||||
|
||||
mcp = FastMCP(
|
||||
"My Phast",
|
||||
instructions="Owner control plane for Phast app manifests. Mutations fail closed until owner auth is installed.",
|
||||
|
||||
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)
|
||||
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"]
|
||||
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}"
|
||||
+ "@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><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,
|
||||
)
|
||||
|
||||
|
||||
def _require_registry() -> AppRegistry:
|
||||
if registry is None:
|
||||
raise RegistryUnavailable(registry_blocker or "durable registry is offline")
|
||||
return registry
|
||||
|
||||
|
||||
def _require_owner_auth() -> None:
|
||||
# Deliberately not an environment-secret pseudo-auth scheme. Replace this
|
||||
# only with an accepted reusable FastMCP owner identity provider.
|
||||
raise PermissionError(AUTH_BLOCKER)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
|
||||
@m.tool()
|
||||
def phast_status() -> dict:
|
||||
"""Get public Phast endpoints and control-plane readiness."""
|
||||
return {"ok": True, "actor": actor(), "version": "0.2.0"}
|
||||
|
||||
|
||||
@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 {
|
||||
"ok": True,
|
||||
"service": "phast",
|
||||
"url": "https://phast.app",
|
||||
"owner_url": "https://my.phast.app",
|
||||
"mcp": "https://my.phast.app/mcp",
|
||||
"registry_ready": registry is not None,
|
||||
"registry_blocker": registry_blocker,
|
||||
"mutations_ready": False,
|
||||
"auth_blocker": AUTH_BLOCKER,
|
||||
"phast": data,
|
||||
"human_url": "https://" + data["host"] + "/",
|
||||
"mcp_url": "https://" + data["host"] + "/mcp",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool(name="apps_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
|
||||
def apps_list() -> dict:
|
||||
"""List the owner's registered Phast app manifests."""
|
||||
return {"apps": _require_registry().list()}
|
||||
@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)
|
||||
|
||||
|
||||
@mcp.tool(name="apps_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
|
||||
def apps_get(slug: str) -> dict:
|
||||
"""Get one registered Phast app manifest by slug."""
|
||||
result = _require_registry().get(slug)
|
||||
if result is None:
|
||||
raise KeyError(f"unknown app: {slug}")
|
||||
return result
|
||||
@m.tool()
|
||||
def providers_search(query: str) -> dict:
|
||||
return {"query": query, "source": "oracle", "status": "adapter-next"}
|
||||
|
||||
|
||||
@mcp.tool(name="apps_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False})
|
||||
def apps_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict:
|
||||
"""Create a Phast app manifest; disabled until owner auth is installed."""
|
||||
_require_owner_auth()
|
||||
return _require_registry().create(
|
||||
slug=slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(name="apps_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True})
|
||||
def apps_update(slug: str, name: str | None = None, base_url: str | None = None,
|
||||
human_route: str | None = None, mcp_route: str | None = None) -> dict:
|
||||
"""Update a Phast app manifest; disabled until owner auth is installed."""
|
||||
_require_owner_auth()
|
||||
return _require_registry().update(
|
||||
slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route
|
||||
)
|
||||
|
||||
|
||||
def control_app() -> PrefabApp:
|
||||
apps = registry.list() if registry is not None else []
|
||||
with PrefabApp(title="My Phast", css_class="max-w-4xl mx-auto p-6", mode="light") as result:
|
||||
with Column(gap=5):
|
||||
with Row(gap=3, justify="between", align="center"):
|
||||
with Column(gap=1):
|
||||
Text("PHAST · OWNER CONTROL PLANE", code=True)
|
||||
Heading("My Phast", level=1)
|
||||
Badge("read-only" if registry is not None else "setup required", variant="warning")
|
||||
Text("Your apps share one boring convention: a human surface at / and an agent surface at /mcp.")
|
||||
with Card(css_class="p-5"):
|
||||
with Column(gap=3):
|
||||
with Row(gap=2, justify="between", align="center"):
|
||||
Heading("Apps", level=2)
|
||||
Badge(str(len(apps)), variant="secondary")
|
||||
if apps:
|
||||
for item in apps:
|
||||
with Card(css_class="p-4"):
|
||||
with Row(gap=3, justify="between", align="center"):
|
||||
with Column(gap=1):
|
||||
Heading(item["name"], level=3)
|
||||
Text(item["base_url"], code=True)
|
||||
Badge(item["runtime"], variant="outline")
|
||||
Text(f"human {item['human']['route']} · mcp {item['mcp']['route']}")
|
||||
else:
|
||||
Text("No durable app registry is available yet.")
|
||||
with Card(css_class="p-5"):
|
||||
with Column(gap=3):
|
||||
Heading("Control-plane readiness", level=2)
|
||||
Text(registry_blocker or "Durable SQLite registry is mounted and readable.")
|
||||
Text(AUTH_BLOCKER)
|
||||
Button("Create app", disabled=True)
|
||||
Separator()
|
||||
Text("Agent interface", bold=True)
|
||||
Code("https://my.phast.app/mcp", language="text")
|
||||
Text("Mutations stay disabled until both a Fly volume and accepted owner authentication exist.")
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool(name="my_phast_app", app=True, annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
|
||||
def my_phast_app() -> PrefabApp:
|
||||
"""Open the read-only My Phast Prefab control surface."""
|
||||
return control_app()
|
||||
|
||||
|
||||
@mcp.custom_route("/", methods=["GET"])
|
||||
async def homepage(request: Request):
|
||||
host = request.headers.get("host", "").split(":", 1)[0].lower()
|
||||
if host == OWNER_HOST:
|
||||
return HTMLResponse(control_app().html(renderer_mode="bundled"))
|
||||
return HTMLResponse(Path(__file__).with_name("index.html").read_text())
|
||||
|
||||
|
||||
@mcp.custom_route("/healthz", methods=["GET"])
|
||||
async def health(_: Request):
|
||||
return JSONResponse(
|
||||
{
|
||||
@personal.tool()
|
||||
def phast_status() -> dict:
|
||||
owned = mine(value)
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "phast",
|
||||
"source_sha": os.environ.get("SOURCE_SHA", "local"),
|
||||
"control_plane": {
|
||||
"registry_ready": registry is not None,
|
||||
"mutations_ready": False,
|
||||
"blockers": [value for value in (registry_blocker, AUTH_BLOCKER) if value],
|
||||
},
|
||||
"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]
|
||||
|
||||
|
||||
app = mcp.http_app(
|
||||
path="/mcp",
|
||||
transport="streamable-http",
|
||||
stateless_http=True,
|
||||
allowed_hosts=["phast.app", "www.phast.app", "my.phast.app", "phast-app.fly.dev", "localhost", "testserver"],
|
||||
)
|
||||
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="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
|
||||
uvicorn.run(app, host=os.getenv("PHAST_HOST", "::"), port=int(os.getenv("PORT", "8081")))
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# The Fly volume mounts root-owned at /data: make our dirs, hand them to the app user, drop root.
|
||||
set -e
|
||||
for d in "${PHAST_DATA_DIR:-/data/phast}" "${FASTMCP_HOME:-/data/fastmcp}"; do
|
||||
mkdir -p "$d" && chown -R 10001:10001 "$d" && chmod 700 "$d"
|
||||
done
|
||||
exec env HOME=/home/phast setpriv --reuid=10001 --regid=10001 --clear-groups python server.py
|
||||
Reference in new issue
Block a user