From 3908251f6edfa5608cf6b667a793d0e3d6e021e2 Mon Sep 17 00:00:00 2001
From: Chris Farhood
Date: Sat, 4 Jul 2026 21:25:12 -0400
Subject: [PATCH] feat: base-path support (ROOT_PATH) for serving under /portal
Links, form actions, and redirects are prefixed with the gateway path prefix so
the portal can be served at intervalsicu.farhoodlabs.com/portal (gateway strips
/portal; FastAPI root_path generates correct URLs). Connector URL is configurable.
---
.coverage | Bin 53248 -> 53248 bytes
src/intervalsicu_mcp_ui/app.py | 29 +++++++++++-------
src/intervalsicu_mcp_ui/config.py | 4 +++
.../templates/account.html | 4 +--
src/intervalsicu_mcp_ui/templates/admin.html | 6 ++--
src/intervalsicu_mcp_ui/templates/base.html | 6 ++--
src/intervalsicu_mcp_ui/templates/login.html | 2 +-
tests/test_app.py | 2 ++
8 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/.coverage b/.coverage
index c279b8dc095a5479f9cbdce3034d80c0bf76a299..d27ba23191cf378734c49cd8297d9af4270c0e64 100644
GIT binary patch
delta 83
zcmV-Z0IdIjpaX!Q1F!~w4yynU`406C>JHisw+*Ya5fGORv#gCj0VoLr1OW*Y1#133
p0fGSn1px_!0s;c4?^5p1`^?M?0Ko9~>mU1jy}OZ-GP6XFyg=r$Ashez
delta 82
zcmV-Y0ImOkpaX!Q1F!~w4y*tV`406C>kiuuxDBkc5fGRSv#X6i0VfCp1OW*W1#13J
of&l^r0SSZx0s^S-Qtr>2nHd0p;qTW!_V;>s0g;h1vqX=)K)@^?fB*mh
diff --git a/src/intervalsicu_mcp_ui/app.py b/src/intervalsicu_mcp_ui/app.py
index 384870b..2302cbd 100644
--- a/src/intervalsicu_mcp_ui/app.py
+++ b/src/intervalsicu_mcp_ui/app.py
@@ -43,18 +43,25 @@ def create_app(config: Config | None = None) -> FastAPI:
client_kwargs={"scope": cfg.oidc_scopes},
)
- app = FastAPI(title="Intervals.icu MCP portal")
+ app = FastAPI(title="Intervals.icu MCP portal", root_path=cfg.root_path)
app.add_middleware(SessionMiddleware, secret_key=cfg.session_secret, https_only=True, same_site="lax")
app.state.cfg = cfg
app.state.oauth = oauth
def render(request, name, **ctx):
- return _TEMPLATES.TemplateResponse(request, name, {"user": current_user(request), **ctx})
+ return _TEMPLATES.TemplateResponse(
+ request,
+ name,
+ {"user": current_user(request), "base": cfg.root_path, "mcp_url": cfg.mcp_url, **ctx},
+ )
+
+ def redirect(path: str, status_code: int = 307):
+ return RedirectResponse(f"{cfg.root_path}{path}", status_code=status_code)
# ----- auth ----------------------------------------------------------- #
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, user: dict | None = Depends(current_user)):
- return RedirectResponse("/account" if user else "/login")
+ return redirect("/account" if user else "/login")
@app.get("/login", response_class=HTMLResponse)
async def login(request: Request):
@@ -70,7 +77,7 @@ def create_app(config: Config | None = None) -> FastAPI:
claims = dict(token.get("userinfo") or {})
sub = claims.get("sub")
if not sub:
- return RedirectResponse("/login")
+ return redirect("/login")
email = claims.get("email", "")
name = claims.get("name")
groups = claims.get("groups") or []
@@ -82,18 +89,18 @@ def create_app(config: Config | None = None) -> FastAPI:
"name": name,
"is_admin": cfg.admin_group in groups,
}
- return RedirectResponse("/account")
+ return redirect("/account")
@app.get("/logout")
async def logout(request: Request):
request.session.clear()
- return RedirectResponse("/login")
+ return redirect("/login")
# ----- account -------------------------------------------------------- #
@app.get("/account", response_class=HTMLResponse)
async def account(request: Request, user: dict | None = Depends(current_user)):
if not user:
- return RedirectResponse("/login")
+ return redirect("/login")
async with db.sessionmaker()() as session:
record = await db.get_user(session, user["sub"])
return render(request, "account.html", record=record)
@@ -106,7 +113,7 @@ def create_app(config: Config | None = None) -> FastAPI:
user: dict | None = Depends(current_user),
):
if not user:
- return RedirectResponse("/login")
+ return redirect("/login")
ok, message = await validate_credentials(cfg.intervals_api_base, athlete_id, api_key)
async with db.sessionmaker()() as session:
if ok:
@@ -124,7 +131,7 @@ def create_app(config: Config | None = None) -> FastAPI:
@app.get("/admin", response_class=HTMLResponse)
async def admin(request: Request, admin_user: dict | None = Depends(require_admin)):
if not admin_user:
- return RedirectResponse("/account")
+ return redirect("/account")
async with db.sessionmaker()() as session:
users = await db.list_users(session)
return render(request, "admin.html", users=users)
@@ -135,7 +142,7 @@ def create_app(config: Config | None = None) -> FastAPI:
admin_user: dict | None = Depends(require_admin),
):
if not admin_user:
- return RedirectResponse("/account")
+ return redirect("/account")
async with db.sessionmaker()() as session:
if action == "enable":
await db.set_enabled(session, sub, True)
@@ -143,7 +150,7 @@ def create_app(config: Config | None = None) -> FastAPI:
await db.set_enabled(session, sub, False)
elif action == "delete":
await db.delete_user(session, sub)
- return RedirectResponse("/admin", status_code=303)
+ return redirect("/admin", status_code=303)
@app.get("/healthz")
async def healthz():
diff --git a/src/intervalsicu_mcp_ui/config.py b/src/intervalsicu_mcp_ui/config.py
index 1d1d143..dd699c8 100644
--- a/src/intervalsicu_mcp_ui/config.py
+++ b/src/intervalsicu_mcp_ui/config.py
@@ -17,6 +17,8 @@ class Config:
admin_group: str
intervals_api_base: str
oidc_scopes: str
+ root_path: str # public path prefix when served behind a gateway (e.g. "/portal")
+ mcp_url: str # the MCP connector URL to show users
def load_config() -> Config:
@@ -30,4 +32,6 @@ def load_config() -> Config:
admin_group=os.environ.get("ADMIN_GROUP", "intervalsicu-mcp-admins"),
intervals_api_base=os.environ.get("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1"),
oidc_scopes=os.environ.get("OIDC_SCOPES", "openid email profile groups"),
+ root_path=os.environ.get("ROOT_PATH", "").rstrip("/"),
+ mcp_url=os.environ.get("MCP_URL", "https://intervalsicu.farhoodlabs.com/mcp"),
)
diff --git a/src/intervalsicu_mcp_ui/templates/account.html b/src/intervalsicu_mcp_ui/templates/account.html
index f163c54..109e788 100644
--- a/src/intervalsicu_mcp_ui/templates/account.html
+++ b/src/intervalsicu_mcp_ui/templates/account.html
@@ -20,7 +20,7 @@
Find these in Intervals.icu → Settings → Developer. Your athlete ID looks like i123456.
-