From 9673174b3678869b4f9ad77a84c87611fc647461 Mon Sep 17 00:00:00 2001 From: Arden de Raaij Date: Fri, 13 Nov 2026 17:22:51 +0000 Subject: [PATCH 1/2] Correct CLAUDE.md: this deployment uses Tailscale Serve, not Funnel `tailscale serve status` on the host reports all three ports as tailnet only, and the hostname has no public DNS record. Nothing is reachable from the internet, which matters for anything that calls in from a provider's cloud (hosted MCP connectors). --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37a5bdb..4b80277 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ mcp/ The authenticated React dashboard replaces the old unauthenticated server-rendered one. It is served **same-origin by FastAPI**: the Docker build bakes `frontend/dist` into the image at `/app/static` (`SPA_DIST` env var), `main.py` serves it at `/` with an SPA -fallback for client-side routes. No CORS needed; the Tailscale Funnel setup is unchanged. +fallback for client-side routes. No CORS needed; the Tailscale Serve setup is unchanged. ```bash cd frontend @@ -115,7 +115,7 @@ change/reset, token create/revoke, and user create/deactivate/reactivate. FKs ar (failed logins never resolve to a user). Events stage on the caller's session so they commit atomically with the change they describe; the rate-limit handler (`main.py`) uses its own short-lived session. The events endpoint opportunistically prunes rows ->365d on read (no timer). The publicly-Funnel-exposed login endpoint made this the +>365d on read (no timer). A login endpoint that may be publicly exposed (Funnel, Caddy) made this the genuinely monitoring-shaped addition — failed-login/rate-limit visibility. ## Development @@ -318,4 +318,4 @@ docker compose up -d --build # Deploy changes docker compose logs -f backend # Check logs ``` -This deployment is exposed via Tailscale Funnel (HTTPS on :8443, proxying the API on :8001) for iPhone app access. +This deployment is served by **Tailscale Serve, tailnet-only** (HTTPS on :8443 → `127.0.0.1:8001`, Let's Encrypt cert managed by Tailscale) for iPhone app access. It is **not** on Funnel: the hostname has no public DNS record and nothing is reachable from the internet (verified 2026-11-13 with `tailscale serve status` on the host; earlier versions of this file said Funnel). Cloud-hosted MCP connectors (claude.ai, mobile) therefore cannot reach it. From 9a2b80089cc5302ba379f16da242a034b1634a08 Mon Sep 17 00:00:00 2001 From: Arden de Raaij Date: Fri, 13 Nov 2026 17:22:51 +0000 Subject: [PATCH 2/2] Add a "Connect your coach" flow to the dashboard Connecting an MCP client meant creating a token in Settings, then going to the README for a snippet and substituting the server address and the token by hand. Settings now has an AI coach card: pick Claude Desktop, Claude Code, Cursor, VS Code or another client, and it creates a token and shows the steps and the snippet with both already filled in. The address is the origin the dashboard is open at, so it is right for however the athlete reaches the server; the Claude Desktop snippet adds --allow-http when that origin is plain http. The VS Code config keeps the token out of the file, so the token is shown and copied separately. The card is hidden for admins, who have no training data, and when the endpoint is off: /api/health now reports `mcp` for that. The sidebar's liveness probe and Settings share one query. Copying falls back to the legacy copy command, because the clipboard API does not exist on a plain-http origin, and the button says so when nothing was copied. --- CHANGELOG.md | 11 ++ README.md | 2 +- backend/app/routes/health.py | 4 + backend/tests/test_health.py | 7 ++ frontend/src/components/Layout.tsx | 11 +- frontend/src/lib/coachSetup.ts | 122 ++++++++++++++++++++++ frontend/src/lib/queries.ts | 19 ++++ frontend/src/screens/Settings.tsx | 161 ++++++++++++++++++++++++++++- frontend/src/styles/settings.css | 33 ++++++ 9 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/coachSetup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32491a3..c875bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ tagged `X.Y.Z` and `X.Y` to GHCR (see README "Releases & upgrading"). The running server reports its version at `/api/health` and on the admin System screen. +## [Unreleased] + +### Added + +- **"Connect your coach" in the dashboard.** Settings has an AI coach card for + athletes: pick Claude Desktop, Claude Code, Cursor, VS Code or another client, + and it creates a token and shows the setup with the server address and token + already filled in, ready to copy. Hidden for admins and when + `MCP_ENABLED=false`; `/api/health` now reports `mcp` so the dashboard can + tell. + ## [0.1.17] — 2026-11-12 ### Added diff --git a/README.md b/README.md index 8d3cbf0..b974bfe 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ Each person authenticates with their **own** token in an `Authorization: Bearer` ### Connect a client -Substitute your server's address and your token in the snippets below. +The quickest way: in the dashboard, **Settings → AI coach → Connect** creates a token and shows the setup for your client with the server address and token already filled in. The snippets below are the same thing by hand; substitute your server's address and your token. **Claude Code** diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py index 30b9bdb..dc2bc01 100644 --- a/backend/app/routes/health.py +++ b/backend/app/routes/health.py @@ -2,6 +2,7 @@ from sqlalchemy import text from app.database import DbSession +from app.mcp_mount import MCP_ENABLED from app.version import __version__ router = APIRouter() @@ -19,4 +20,7 @@ def health(db: DbSession): "service": "training-api", "version": __version__, "database": db_status, + # Lets the dashboard hide its "connect your coach" setup when the + # endpoint is switched off. Not a secret: anyone can probe /mcp. + "mcp": MCP_ENABLED, } diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 872a703..2633ac8 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -13,3 +13,10 @@ def test_health_reports_version(): assert body["service"] == "training-api" assert body["version"] == __version__ assert body["database"] == "ok" + + +def test_health_reports_whether_the_coach_endpoint_is_served(): + # The dashboard hides its "connect your coach" setup when this is false. + from app.mcp_mount import MCP_ENABLED + + assert TestClient(app).get("/api/health").json()["mcp"] is MCP_ENABLED diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f7004b3..1ebba44 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query' import { Bell, Brain, @@ -17,9 +16,8 @@ import { import type { Icon } from '@phosphor-icons/react' import { useState } from 'react' import { NavLink, Outlet, useNavigate } from 'react-router-dom' -import { api } from '../lib/api' import { useAuth } from '../lib/auth' -import { useQueue } from '../lib/queries' +import { useLiveness, useQueue } from '../lib/queries' import { LogoMark } from './LogoMark' import { usePageHeaderValue } from './PageHeader' import '../styles/layout.css' @@ -51,12 +49,7 @@ export function Layout() { const pending = useQueue('pending', 100, !isAdmin) const pendingCount = pending.data?.length ?? 0 - const health = useQuery({ - queryKey: ['liveness'], - queryFn: () => api.get<{ service: string; database: string }>('/api/health'), - refetchInterval: 60_000, - retry: false, - }) + const health = useLiveness() const online = health.isSuccess && health.data.database === 'ok' const probing = health.isPending diff --git a/frontend/src/lib/coachSetup.ts b/frontend/src/lib/coachSetup.ts new file mode 100644 index 0000000..f13241b --- /dev/null +++ b/frontend/src/lib/coachSetup.ts @@ -0,0 +1,122 @@ +// Ready-to-paste setup for pointing an MCP client at this server's coach +// endpoint. Pure string building, so the dashboard can fill in the address the +// athlete is already using and a freshly minted token. Keep in step with the +// "Connect a client" section of the README, which is the long-form version. + +export type CoachClientId = 'claude-desktop' | 'claude-code' | 'cursor' | 'vscode' | 'other' + +export interface CoachClient { + id: CoachClientId + label: string +} + +export const COACH_CLIENTS: CoachClient[] = [ + { id: 'claude-desktop', label: 'Claude Desktop' }, + { id: 'claude-code', label: 'Claude Code' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'vscode', label: 'VS Code' }, + { id: 'other', label: 'Other' }, +] + +export interface CoachSetup { + steps: string[] + snippet: string + // False when the snippet deliberately leaves the token out (VS Code prompts + // for it), so the token has to be shown and copied on its own. + snippetHasToken: boolean +} + +const SERVER_NAME = 'training' + +/** The coach endpoint lives on the same origin as the dashboard. */ +export function coachUrl(origin: string): string { + return `${origin.replace(/\/+$/, '')}/mcp` +} + +/** mcp-remote refuses plain http to anything but the local machine. */ +export function needsAllowHttp(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(u.hostname) + } catch { + return false + } +} + +function json(value: unknown): string { + return JSON.stringify(value, null, 2) +} + +export function buildCoachSetup(client: CoachClientId, url: string, token: string): CoachSetup { + const bearer = `Bearer ${token}` + + switch (client) { + case 'claude-desktop': { + // The header value goes through an env var because Claude Desktop on + // Windows mishandles spaces inside args. + const args = ['-y', 'mcp-remote', url, '--header', 'Authorization:${AUTH_HEADER}'] + if (needsAllowHttp(url)) args.push('--allow-http') + return { + steps: [ + 'Install Node.js 18 or newer from nodejs.org if you don’t have it.', + 'In Claude Desktop, open Settings → Developer → Edit config.', + 'Paste the block below. If the file already has "mcpServers", add only the "training" entry inside it.', + 'Quit Claude Desktop completely and reopen it. Settings → Developer should show "training" as Running.', + ], + snippet: json({ + mcpServers: { + [SERVER_NAME]: { command: 'npx', args, env: { AUTH_HEADER: bearer } }, + }, + }), + snippetHasToken: true, + } + } + case 'claude-code': + return { + steps: ['Run this in a terminal.', 'Start Claude Code and ask “What did I run this week?”'], + snippet: `claude mcp add ${SERVER_NAME} --transport http \\\n ${url} \\\n --header "Authorization: ${bearer}"`, + snippetHasToken: true, + } + case 'cursor': + return { + steps: [ + 'Open ~/.cursor/mcp.json (create it if it doesn’t exist).', + 'Paste the block below. If the file already has "mcpServers", add only the "training" entry inside it.', + 'Restart Cursor.', + ], + snippet: json({ + mcpServers: { [SERVER_NAME]: { url, headers: { Authorization: bearer } } }, + }), + snippetHasToken: true, + } + case 'vscode': + return { + steps: [ + 'Create .vscode/mcp.json in your workspace and paste the block below.', + 'VS Code asks for the token the first time the server starts. Paste the token shown underneath.', + ], + snippet: json({ + inputs: [ + { type: 'promptString', id: 'training-token', description: 'Training API token', password: true }, + ], + servers: { + [SERVER_NAME]: { + type: 'http', + url, + headers: { Authorization: 'Bearer ${input:training-token}' }, + }, + }, + }), + snippetHasToken: false, + } + case 'other': + return { + steps: [ + 'Add a streamable-HTTP MCP server in your client with this address and header.', + 'Clients that run in the cloud can’t reach a server that is only on your private network.', + ], + snippet: `URL: ${url}\nHeader: Authorization: ${bearer}`, + snippetHasToken: true, + } + } +} diff --git a/frontend/src/lib/queries.ts b/frontend/src/lib/queries.ts index 6513286..559be6e 100644 --- a/frontend/src/lib/queries.ts +++ b/frontend/src/lib/queries.ts @@ -460,3 +460,22 @@ export function useAcknowledgeFeedback() { onSuccess: () => qc.invalidateQueries({ queryKey: ['feedback'] }), }) } + +// ── liveness ── + +export interface Liveness { + service: string + database: string + // Absent on servers older than the flag; treat that as "enabled". + mcp?: boolean +} + +/** The unauthenticated health probe, shared by the sidebar status dot and Settings. */ +export function useLiveness() { + return useQuery({ + queryKey: ['liveness'], + queryFn: () => api.get('/api/health'), + refetchInterval: 60_000, + retry: false, + }) +} diff --git a/frontend/src/screens/Settings.tsx b/frontend/src/screens/Settings.tsx index 6a6e44a..4750981 100644 --- a/frontend/src/screens/Settings.tsx +++ b/frontend/src/screens/Settings.tsx @@ -5,7 +5,9 @@ import { usePageHeader } from '../components/PageHeader' import { ConfirmDialog, ErrorNote, Loading, Modal, SectionLabel } from '../components/ui' import { useAuth } from '../lib/auth' import { clientLabel, fmtDayYear, relTime } from '../lib/format' -import { useChangePassword, useMe, useMintToken, useRevokeToken } from '../lib/queries' +import { COACH_CLIENTS, buildCoachSetup, coachUrl } from '../lib/coachSetup' +import type { CoachClientId } from '../lib/coachSetup' +import { useChangePassword, useLiveness, useMe, useMintToken, useRevokeToken } from '../lib/queries' import type { ApiTokenInfo } from '../lib/types' import { setUnits, useUnits } from '../lib/units' import '../styles/settings.css' @@ -229,6 +231,134 @@ function NewTokenModal({ onClose }: { onClose: () => void }) { ) } +// The clipboard API only exists on secure origins, and a tailnet install is +// often plain http, so fall back to the legacy copy command and report +// honestly whether anything landed on the clipboard. +async function copyText(text: string): Promise { + try { + if (navigator.clipboard) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + // fall through to the legacy path + } + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + let ok = false + try { + ok = document.execCommand('copy') + } catch { + ok = false + } + ta.remove() + return ok +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle') + return ( + + ) +} + +function ConnectCoachModal({ onClose }: { onClose: () => void }) { + const mint = useMintToken() + const [client, setClient] = useState('claude-desktop') + const clientLabel = COACH_CLIENTS.find((c) => c.id === client)?.label ?? '' + const url = coachUrl(window.location.origin) + const token = mint.data?.token + const setup = token ? buildCoachSetup(client, url, token) : null + + return ( + +
+ Connect your coach +
+
+ Let an AI assistant read your training data and plan with you. Pick the app you use. +
+ +
+ {COACH_CLIENTS.map((c) => ( + + ))} +
+ + {setup && token ? ( + <> +
    + {setup.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
{setup.snippet}
+ {!setup.snippetHasToken && ( +
+ {token} +
+ )} +

+ {setup.snippetHasToken ? 'This contains your token. ' : 'This is your token. '} + It is shown only this once; revoke it under Devices & + tokens if it leaks. +

+
+ + {!setup.snippetHasToken && } + +
+ + ) : ( + <> +
+ This creates a token named Coach · {clientLabel} and shows the setup with it filled in. + The coach connects to {url}, so the + app has to be able to reach this address. +
+ {mint.error != null && ( +
+ +
+ )} +
+ + +
+ + )} +
+ ) +} + export function Settings() { const { user, tokenId, logout } = useAuth() const { data, isLoading, error } = useMe() @@ -238,11 +368,15 @@ export function Settings() { const [confirmLogout, setConfirmLogout] = useState(false) const [changingPassword, setChangingPassword] = useState(false) const [mintingToken, setMintingToken] = useState(false) + const [connectingCoach, setConnectingCoach] = useState(false) + const liveness = useLiveness() usePageHeader('Settings', data ? `${data.tokens.length} active tokens` : undefined) const me = data?.user ?? user const initial = (me?.displayName || me?.username || '?').charAt(0).toUpperCase() + // Admins have no training data, and a server can switch the endpoint off. + const canConnectCoach = me?.role !== 'admin' && liveness.data?.mcp !== false return (
@@ -286,6 +420,30 @@ export function Settings() {
+ {canConnectCoach && ( + <> +
+ AI coach +
+
+ + + +
+
+ Connect Claude or another assistant +
+
+ ready-to-paste setup for Claude Desktop, Claude Code, Cursor and VS Code +
+
+ +
+ + )} +
Devices & tokens