Give claude.ai a token of its own, sent as a request header
claude.ai's connector dialog does offer request headers, on its second step, after the URL has been probed, so the connector no longer needs the secret path. MCP_AUTH_TOKEN already worked there as a bearer or X-Api-Key, but it also opens /api, which can replace the Schulcloud token and stream the file mirror, and claude.ai stores the header's value. MCP_CONNECTOR_TOKEN is a second token, accepted on /mcp only and refused on /api, and rotated without touching Claude Code or the CLI. The config refuses one shorter than 32 characters, equal to MCP_AUTH_TOKEN, or set without it, and never echoes a value. Every accepted token is compared in full, so the timing does not tell which one matched. The gate also takes a bare Authorization value, because claude.ai sends a header exactly as typed and its docs warn that most servers reject a token entered without "Bearer ". It takes X-Auth-Token too, the other name its dialog offers. The docs now set up the header; the secret path stays as a fallback for clients that cannot send one. 184 tests. Smoke 79/79 and 77/77 on the local instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
34
docs/AUTH.md
34
docs/AUTH.md
@@ -185,9 +185,11 @@ files, so it is not done here.
|
||||
Distinct from the above, and just as important. The MCP endpoint is reachable
|
||||
from the public internet by construction: Claude's connectors call it from
|
||||
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
|
||||
a shared secret checked in constant time on every `/mcp` request
|
||||
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
|
||||
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
|
||||
a shared secret checked in constant time on every `/mcp` and `/api` request
|
||||
(`src/http/auth.ts`). It is accepted as `Authorization: Bearer …`, as a bare
|
||||
`Authorization` value — claude.ai sends a header exactly as typed — or as
|
||||
`X-Api-Key` or `X-Auth-Token`. `/healthz` is deliberately open and reveals
|
||||
nothing.
|
||||
|
||||
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
|
||||
warning and serves unauthenticated — only acceptable bound to localhost.
|
||||
@@ -195,25 +197,35 @@ warning and serves unauthenticated — only acceptable bound to localhost.
|
||||
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, recreate the container, and
|
||||
update Claude Code and the CLI (`schulcloud login`). Nothing else stores it.
|
||||
|
||||
### The secret path, for claude.ai
|
||||
### The connector token, for claude.ai
|
||||
|
||||
claude.ai's connector dialog takes a URL and no header, so `MCP_PATH_SECRET`
|
||||
opens a second way in: `/<secret>/mcp`, with no bearer token. The path is the
|
||||
credential there. `http/auth.ts` compares it in constant time and answers a
|
||||
claude.ai sends a request header whose value it stores, so its token is a
|
||||
credential held by a third party. `MCP_CONNECTOR_TOKEN` gives it one of its
|
||||
own: accepted on `/mcp` alone, refused on `/api` — which can replace the
|
||||
Schulcloud token and stream the file mirror — and rotated without touching
|
||||
Claude Code or the CLI. `config.ts` requires at least 32 characters, a value
|
||||
different from `MCP_AUTH_TOKEN`, and `MCP_AUTH_TOKEN` itself, so it can never
|
||||
leave `/api` unguarded; its errors never echo a value. All accepted tokens are
|
||||
compared in full, so the timing does not tell which one matched.
|
||||
|
||||
### The secret path, for clients without request headers
|
||||
|
||||
For a client that can send no header, `MCP_PATH_SECRET` opens another way in:
|
||||
`/<secret>/mcp`, with no token at all. The path is the credential there. `http/auth.ts` compares it in constant time and answers a
|
||||
wrong one with the same 404 as any unknown path; `config.ts` insists on at
|
||||
least 32 URL-safe characters and never echoes the value; the server never logs
|
||||
request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an
|
||||
access log entry is written. Rotating it means a new value, a recreated
|
||||
container, and re-adding the connector. It is a stopgap: OAuth is how
|
||||
connectors are meant to authenticate, and it would make the URL a plain
|
||||
address again.
|
||||
container, and re-adding the connector. Prefer the connector token wherever a
|
||||
header can be sent: a URL is copied into more places than a header is.
|
||||
|
||||
## Blast radius
|
||||
|
||||
Every path in this server is a `GET`, including the `api_get` escape hatch,
|
||||
which rejects anything not starting with `/api/` and anything carrying a scheme
|
||||
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or
|
||||
the secret MCP path — could read this account's Schulcloud data; they could not
|
||||
the connector token, or the secret MCP path — could read this account's
|
||||
Schulcloud data; they could not
|
||||
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
||||
could also call `PUT /api/token`, but it accepts only a live token for the same
|
||||
account, so the most it can do is hand the server a session the owner already
|
||||
|
||||
@@ -25,7 +25,7 @@ Claude's custom connectors call the endpoint from Anthropic's cloud
|
||||
localhost tunnel or self-signed cert will not do. The VPS provides the public
|
||||
address; Caddy on the Pi terminates TLS and obtains the certificate. **Keep the
|
||||
VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees
|
||||
a request path, which matters once a path carries a secret (below).
|
||||
a request, whose header or path carries a credential (below).
|
||||
|
||||
**The Pi must stay up.** More than two hours offline ends the Schulcloud session
|
||||
however long the token has left — a laptop that sleeps overnight loses it every
|
||||
@@ -137,22 +137,42 @@ newline from a copy-paste.
|
||||
|
||||
## Connecting Claude
|
||||
|
||||
### claude.ai — a secret path, for now
|
||||
### claude.ai — a request header with a token of its own
|
||||
|
||||
claude.ai's *Add custom connector* dialog takes a name and a URL. Sending a
|
||||
bearer token needs its "Request headers" section, a beta most accounts do not
|
||||
have, and the proper answer — OAuth — is not built yet. Until it is, the
|
||||
server can serve MCP at a path that is itself the secret:
|
||||
claude.ai's *Add custom connector* dialog asks for a name and a URL first. Its
|
||||
authentication settings, **Request headers** among them, appear on the next
|
||||
step, once it has probed the URL.
|
||||
|
||||
1. Put `MCP_CONNECTOR_TOKEN=<openssl rand -hex 32>` in `.env` and recreate the
|
||||
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
|
||||
startup line then says `(plus connector token)`.
|
||||
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
|
||||
give the URL `https://mcp.example.org/mcp`.
|
||||
3. On the next step keep **No sign-in**, which is what Claude detects, and add a
|
||||
request header: name `authorization`, value `Bearer <MCP_CONNECTOR_TOKEN>`,
|
||||
space included. A bare token, or the header `x-api-key`, works too.
|
||||
4. Enable it in a conversation via **+ → Connectors**.
|
||||
|
||||
Ask *"which courses am I in?"* as a first check — that exercises the header,
|
||||
the Schulcloud token and the API in one call.
|
||||
|
||||
**Why a token of its own.** claude.ai stores the header value, so the token is
|
||||
a credential held by a third party. `MCP_CONNECTOR_TOKEN` opens `/mcp`, the
|
||||
read-only tools, and is refused on `/api`, which can replace the Schulcloud
|
||||
token and stream the file mirror. It also rotates alone: set a new value,
|
||||
recreate the container, then remove the connector and add it again, because
|
||||
claude.ai cannot edit a stored header. Claude Code and the CLI are unaffected.
|
||||
|
||||
### Without request headers — a secret path
|
||||
|
||||
For a client that cannot send a header, the server can serve MCP at a path
|
||||
that is itself the secret:
|
||||
|
||||
1. Put `MCP_PATH_SECRET=<openssl rand -hex 32>` in `.env` and recreate the
|
||||
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
|
||||
startup line then says `(plus secret MCP path)` — never the secret itself.
|
||||
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
|
||||
give the URL `https://mcp.example.org/<secret>/mcp`. No sign-in.
|
||||
3. Enable it in a conversation via **+ → Connectors**.
|
||||
|
||||
Ask *"which courses am I in?"* as a first check — that exercises the path, the
|
||||
Schulcloud token and the API in one call.
|
||||
container. The startup line then says `(plus secret MCP path)` — never the
|
||||
secret itself.
|
||||
2. Give the client the URL `https://mcp.example.org/<secret>/mcp`, with no
|
||||
header and no sign-in.
|
||||
|
||||
**Know what this trades away.** The URL is now the credential, and Anthropic's
|
||||
connector documentation calls credentials in URLs a security vulnerability,
|
||||
|
||||
@@ -107,9 +107,9 @@ node dist/bin/cli.js sync
|
||||
## Run the test suites
|
||||
|
||||
```bash
|
||||
npm test # 178 offline tests
|
||||
npm test # 184 offline tests
|
||||
npm run smoke # end-to-end against the live instance, live-only mode
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (76 checks)
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (79 checks)
|
||||
```
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
|
||||
59
docs/PI.md
59
docs/PI.md
@@ -82,7 +82,7 @@ COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
|
||||
CADDY_NETWORK=<network>
|
||||
POSTGRES_PASSWORD=$(openssl rand -hex 24)
|
||||
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
|
||||
MCP_PATH_SECRET=$(openssl rand -hex 32)
|
||||
MCP_CONNECTOR_TOKEN=$(openssl rand -hex 32)
|
||||
INDEX_PERSONAL_FILES=true
|
||||
# SCHULCLOUD_MCP_TAG=latest
|
||||
EOF
|
||||
@@ -96,15 +96,15 @@ What those lines do:
|
||||
| `CADDY_NETWORK` | The network Caddy reaches this server on, by the name `schulcloud-mcp`. |
|
||||
| `POSTGRES_PASSWORD` | The bundled Postgres, which sits on a private network with this server only. Hex, so it needs no escaping inside the connection URL. |
|
||||
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
|
||||
| `MCP_PATH_SECRET` | The secret path claude.ai uses until OAuth exists. |
|
||||
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
||||
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
||||
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
|
||||
|
||||
**Copy `MCP_AUTH_TOKEN` and `MCP_PATH_SECRET` into your password manager now** —
|
||||
you need both again in step 9, and neither is ever printed by the server:
|
||||
**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager
|
||||
now** — you need both again in step 9, and neither is ever printed by the server:
|
||||
|
||||
```bash
|
||||
grep -E '^(MCP_AUTH_TOKEN|MCP_PATH_SECRET)=' .env
|
||||
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN)=' .env
|
||||
```
|
||||
|
||||
## 4. The first Schulcloud token
|
||||
@@ -134,7 +134,7 @@ with the Pi file in place no `docker compose` command can fall back to a build.
|
||||
Expect, within a few seconds:
|
||||
|
||||
```
|
||||
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus secret MCP path), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
|
||||
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus connector token), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
|
||||
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
|
||||
```
|
||||
|
||||
@@ -163,7 +163,8 @@ Keep the snippet's three easily-missed settings:
|
||||
|
||||
- `flush_interval -1`, or claude.ai's connection hangs without an error.
|
||||
- The long timeouts, or a slow `search` is cut off.
|
||||
- The `format filter` in `log`, which keeps the secret path out of the access log.
|
||||
- The `format filter` in `log`, which keeps a secret path out of the access log if
|
||||
you ever use one (step 9).
|
||||
|
||||
Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for
|
||||
it with `docker logs -f <caddy-container> | grep -i certificate`.
|
||||
@@ -174,9 +175,9 @@ it with `docker logs -f <caddy-container> | grep -i certificate`.
|
||||
Publish no `AAAA` record unless the VPS forwards IPv6 as well.
|
||||
|
||||
**The forwarding must pass TCP through, untouched.** TLS has to end at Caddy on
|
||||
the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request
|
||||
path — including the secret one — and may log it. Both ports are needed: 80 for
|
||||
the certificate challenge, 443 for everything else.
|
||||
the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request —
|
||||
the claude.ai token in its header included — and may log it. Both ports are
|
||||
needed: 80 for the certificate challenge, 443 for everything else.
|
||||
|
||||
If the VPS already forwards to the Pi, check how. On the VPS:
|
||||
|
||||
@@ -314,14 +315,29 @@ claude mcp list # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Con
|
||||
|
||||
**claude.ai:**
|
||||
|
||||
1. *Customize → Connectors → Add custom connector*.
|
||||
2. Name: `Schulcloud`. URL: `https://mcp.example.org/<MCP_PATH_SECRET>/mcp`. No sign-in.
|
||||
1. *Customize → Connectors → Add custom connector*. Name: `Schulcloud`. URL:
|
||||
`https://mcp.example.org/mcp`.
|
||||
2. The next step shows **No sign-in** as detected — keep it — and a **Request
|
||||
headers** section. Add one: name `authorization`, value
|
||||
`Bearer <MCP_CONNECTOR_TOKEN>`, with the space. Add the connector.
|
||||
3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse
|
||||
habe ich?"*
|
||||
|
||||
The URL is the credential for as long as the secret path is in use: keep it out
|
||||
of screenshots and notes. [DEPLOYMENT.md](DEPLOYMENT.md#claudeai--a-secret-path-for-now)
|
||||
says what that trades away.
|
||||
claude.ai stores the header and never shows it again; to change it, remove the
|
||||
connector and add it again.
|
||||
|
||||
*Without request headers* — say, for a client that cannot send them — use a
|
||||
secret path instead:
|
||||
|
||||
```bash
|
||||
echo "MCP_PATH_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
docker compose up -d --force-recreate schulcloud-mcp
|
||||
```
|
||||
|
||||
The URL is then `https://mcp.example.org/<MCP_PATH_SECRET>/mcp`, with no header.
|
||||
It is the credential itself, so keep it out of screenshots and notes;
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md#without-request-headers--a-secret-path) says what
|
||||
that trades away.
|
||||
|
||||
**Retire the laptop's container** once the Pi answers — in the laptop checkout,
|
||||
`docker compose down` keeps its index and mirror volumes. Its session is separate
|
||||
@@ -389,7 +405,8 @@ Going back works the same way: set the previous commit id, then `pull` and
|
||||
|---|---|---|
|
||||
| `keepalive: token rejected (401)` | The session ended: the Pi was off for more than two hours, a Schulportal tab was left open, or 30 days passed | Step 11 |
|
||||
| 401 about two hours after pasting a token | A Schulportal tab still open on that login | Close it, then step 11 |
|
||||
| claude.ai cannot add the connector | DNS, forwarding or certificate not in place, or a wrong secret (404) | Step 8's checks, in order |
|
||||
| claude.ai cannot add the connector | DNS, forwarding or certificate not in place; a missing or mistyped request header (401); or, with a secret path, a wrong secret (404) | Step 8's checks, in order, then the header |
|
||||
| The connector token works on `/mcp` but not with the CLI | By design: it is refused on `/api` | The CLI uses `MCP_AUTH_TOKEN` |
|
||||
| claude.ai connects, then tools hang | `flush_interval -1` missing from the Caddy site | Step 6 |
|
||||
| `/mcp` answers 401 with the right token | Whitespace copied along with the token | Re-copy it |
|
||||
| Caddy never obtains a certificate | Port 80 not forwarded, or DNS not yet pointing at the VPS | Step 7 |
|
||||
@@ -408,11 +425,13 @@ Going back works the same way: set the previous commit id, then `pull` and
|
||||
Pi user's home private, and use an account that may only pull if your
|
||||
registry can issue one.
|
||||
- The VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH.
|
||||
- The secret path stays out of Caddy's access log — this prints a count, never
|
||||
the secret:
|
||||
- With a secret path set, it stays out of Caddy's access log — this prints a
|
||||
count, never the secret:
|
||||
```bash
|
||||
docker exec <caddy-container> grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log
|
||||
# 0
|
||||
```
|
||||
- If the claude.ai URL may have leaked: set a new `MCP_PATH_SECRET`, run
|
||||
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the connector.
|
||||
- If claude.ai's token may have leaked: set a new `MCP_CONNECTOR_TOKEN`, run
|
||||
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the
|
||||
connector with the new header. A leaked secret path is replaced the same way.
|
||||
`MCP_AUTH_TOKEN` stays valid either way.
|
||||
|
||||
Reference in New Issue
Block a user