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: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ System screen.
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.
tell. Each app gets its own token, named after it, so one can be revoked
without touching the others.

### Fixed

- **Setup instructions for Claude Desktop and Claude Code.** Node.js 20.18 is
the stated minimum (what the `mcp-remote` helper's dependencies now declare;
the instructions said 18), and the Claude Code
command now uses `--scope user`: without it the server was registered only
for the folder the command happened to be run in.

## [0.1.17] — 2026-11-12

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,16 +342,18 @@ The quickest way: in the dashboard, **Settings → AI coach → Connect** create
**Claude Code**

```bash
claude mcp add training --transport http \
claude mcp add training --scope user --transport http \
http://<machine-name>:8001/mcp \
--header "Authorization: Bearer <your-token>"
```

`--scope user` makes the server available in every folder. To replace an existing entry (for example after rotating the token), run `claude mcp remove training --scope user` first; `add` refuses to overwrite.

**Claude Desktop**

Claude Desktop cannot send a bearer token to a remote server by itself, so a small helper ([`mcp-remote`](https://github.com/geelen/mcp-remote)) does it. Do not use **Settings → Connectors → Add custom connector**: that flow connects from Anthropic's servers and expects OAuth, so it does not work with this server (see [Reachability](#reachability)).

1. **Install [Node.js](https://nodejs.org)** (18 or newer) if you don't have it. The helper runs through `npx`, which comes with Node.
1. **Install [Node.js](https://nodejs.org)** (20.18 or newer) if you don't have it. The helper runs through `npx`, which comes with Node.
2. **Create a token.** In the Loopback dashboard go to **Settings → New token**, name it (e.g. "Claude Desktop") and copy it. It is shown only once.
3. **Open the config file.** In Claude Desktop: **Settings → Developer → Edit config**. This opens `claude_desktop_config.json`:
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
Expand Down
9 changes: 6 additions & 3 deletions backend/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""The unauthenticated health endpoint — the iOS app's version handshake."""

import pytest
from fastapi.testclient import TestClient

from app.main import app
Expand All @@ -15,8 +16,10 @@ def test_health_reports_version():
assert body["database"] == "ok"


def test_health_reports_whether_the_coach_endpoint_is_served():
@pytest.mark.parametrize("enabled", [True, False])
def test_health_reports_whether_the_coach_endpoint_is_served(monkeypatch, enabled):
# The dashboard hides its "connect your coach" setup when this is false.
from app.mcp_mount import MCP_ENABLED
# Patched where the route reads it: health.py imports the value by name.
monkeypatch.setattr("app.routes.health.MCP_ENABLED", enabled)

assert TestClient(app).get("/api/health").json()["mcp"] is MCP_ENABLED
assert TestClient(app).get("/api/health").json()["mcp"] is enabled
23 changes: 17 additions & 6 deletions frontend/src/lib/coachSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ export function coachUrl(origin: string): string {
return `${origin.replace(/\/+$/, '')}/mcp`
}

/** mcp-remote refuses plain http to anything but the local machine. */
/**
* mcp-remote refuses plain http unless told otherwise. It exempts only the
* names `localhost` and `127.0.0.1` (not `[::1]`), and the flag is harmless
* there, so every http address gets it rather than mirroring that list.
*/
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)
return new URL(url).protocol === 'http:'
} catch {
return false
}
Expand All @@ -58,7 +61,7 @@ export function buildCoachSetup(client: CoachClientId, url: string, token: strin
if (needsAllowHttp(url)) args.push('--allow-http')
return {
steps: [
'Install Node.js 18 or newer from nodejs.org if you don’t have it.',
'Install a current Node.js (20.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.',
Expand All @@ -73,8 +76,16 @@ export function buildCoachSetup(client: CoachClientId, url: string, token: strin
}
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}"`,
steps: [
'Run this in a terminal.',
'If it says "training" already exists, run "claude mcp remove training --scope user" and try again.',
'Start Claude Code in any folder and ask “What did I run this week?”',
],
// One line, no continuations: a trailing backslash only means "next
// line" in bash and zsh, and this is pasted into PowerShell and cmd
// too. --scope user, because the default registers the server for the
// current folder only.
snippet: `claude mcp add ${SERVER_NAME} --scope user --transport http ${url} --header "Authorization: ${bearer}"`,
snippetHasToken: true,
}
case 'cursor':
Expand Down
16 changes: 12 additions & 4 deletions frontend/src/screens/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,11 @@ function CopyButton({ text, label }: { text: string; label: string }) {
function ConnectCoachModal({ onClose }: { onClose: () => void }) {
const mint = useMintToken()
const [client, setClient] = useState<CoachClientId>('claude-desktop')
const clientLabel = COACH_CLIENTS.find((c) => c.id === client)?.label ?? ''
const clientName = 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
const locked = token != null || mint.isPending

return (
<Modal onClose={onClose} width={560}>
Expand All @@ -295,6 +296,13 @@ function ConnectCoachModal({ onClose }: { onClose: () => void }) {
<button
key={c.id}
className={`filter-chip${client === c.id ? ' on' : ''}`}
aria-pressed={client === c.id}
// The token is named after the client it was created for, so from
// the moment the request is sent the choice is fixed: another app
// gets its own token, which keeps each one identifiable and
// revocable on its own.
disabled={locked && client !== c.id}
style={locked && client !== c.id ? { opacity: 0.4, cursor: 'default' } : undefined}
onClick={() => setClient(c.id)}
>
{c.label}
Expand All @@ -318,7 +326,7 @@ function ConnectCoachModal({ onClose }: { onClose: () => void }) {
<p className="connect-warn">
{setup.snippetHasToken ? 'This contains your token. ' : 'This is your token. '}
It is shown <strong style={{ color: 'var(--amber)' }}>only this once</strong>; revoke it under Devices &amp;
tokens if it leaks.
tokens if it leaks. To set up another app, close this and connect again: each app gets its own token.
</p>
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button className="btn-ghost" style={{ flex: 1 }} onClick={onClose}>
Expand All @@ -331,7 +339,7 @@ function ConnectCoachModal({ onClose }: { onClose: () => void }) {
) : (
<>
<div style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.55 }}>
This creates a token named <strong>Coach · {clientLabel}</strong> and shows the setup with it filled in.
This creates a token named <strong>Coach · {clientName}</strong> and shows the setup with it filled in.
The coach connects to <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{url}</span>, so the
app has to be able to reach this address.
</div>
Expand All @@ -348,7 +356,7 @@ function ConnectCoachModal({ onClose }: { onClose: () => void }) {
className="btn-accent"
style={{ flex: 1 }}
disabled={mint.isPending}
onClick={() => mint.mutate({ name: `Coach · ${clientLabel}` })}
onClick={() => mint.mutate({ name: `Coach · ${clientName}` })}
>
{mint.isPending ? 'Creating…' : 'Create token & show setup'}
</button>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/styles/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
.connect-steps li + li {
margin-top: 5px;
}
.connect-snippet {
/* Doubled class: these override .cli-box, which is declared further down. */
.cli-box.connect-snippet {
margin-top: 0;
max-height: 260px;
overflow: auto;
Expand Down
Loading