Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
4 changes: 4 additions & 0 deletions backend/app/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
}
7 changes: 7 additions & 0 deletions backend/tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 2 additions & 9 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useQuery } from '@tanstack/react-query'
import {
Bell,
Brain,
Expand All @@ -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'
Expand Down Expand Up @@ -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

Expand Down
122 changes: 122 additions & 0 deletions frontend/src/lib/coachSetup.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
}
19 changes: 19 additions & 0 deletions frontend/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Liveness>('/api/health'),
refetchInterval: 60_000,
retry: false,
})
}
Loading
Loading