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/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. 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