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>
4.6 KiB
Running it locally
Everything runs on your machine: Postgres, the server, the CLI, and Claude Code talking to all of it. Only Schulcloud itself is remote.
Start the stack
cp .env.example .env # fill in TSC_URL, TSC_JWT_COOKIE, MCP_AUTH_TOKEN
npm install && npm run build
docker compose up -d --build
curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":"on"}
docker-compose.override.yml is merged automatically and is local-only: it
publishes the server on 127.0.0.1:8080 and Postgres on 127.0.0.1:55432, and
switches crawling to on-demand. Two .env settings adjust it: MCP_HOST_PORT
moves the published port when 8080 is taken, and CRAWL_INTERVAL_MS restores a
crawl timer — worth doing against a real account, because what_changed can
only report what happened between crawls.
A fresh Schulcloud token goes in with schulcloud token set, or on the page at
http://127.0.0.1:8080/token — no restart; see docs/CLI.md. Editing
TSC_JWT_COOKIE in .env instead needs the container recreated, because
env_file is read when the container is created, not on restart:
docker compose up -d --force-recreate schulcloud-mcp
On the Pi neither port is published — Caddy reaches the container over the Docker network.
Loopback binding is deliberate. The bearer token is the only thing in front of your account's data, so it should not be listening on your LAN while you test.
Populate the index
A full crawl is ~270 requests; start with one course:
node dist/bin/cli.js login --server http://127.0.0.1:8080 --token "$MCP_AUTH_TOKEN"
node dist/bin/cli.js refresh --course <courseId> # or omit --course for everything
node dist/bin/cli.js status
Get a course id from curl -s -H "Authorization: Bearer $TSC_JWT_COOKIE" \ "$TSC_URL/api/v3/courses?limit=5" | jq -r '.data[] | "\(.id) \(.title)"'.
Connect Claude Code
Claude Code supports MCP over stdio, SSE and HTTP. The VS Code extension runs the same Claude Code underneath, so it reads the same configuration — register once and both work.
Use --scope user. Claude Code's default scope is local, which is
per-project: the server is stored under that directory in ~/.claude.json and
claude mcp list will not show it from anywhere else. Since the point is to ask
about your coursework from wherever you happen to be, user scope is what you
want. (project scope would write a .mcp.json into the repo — wrong here,
because the HTTP config carries a token.)
HTTP — matches the deployed setup:
claude mcp add --scope user --transport http schulcloud http://127.0.0.1:8080/mcp \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
stdio — no server or Docker needed:
claude mcp add --scope user schulcloud-stdio \
-e DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud \
-- node --env-file=/absolute/path/to/.env /absolute/path/to/dist/bin/stdio.js
--env-file keeps the JWT in .env rather than copying it into the MCP config.
The HTTP form has no equivalent — its header holds the token — so that token
lives in ~/.claude.json.
Check it:
claude mcp list # schulcloud: http://127.0.0.1:8080/mcp (HTTP) - ✔ Connected
claude mcp get schulcloud
If claude mcp list does not show it, the usual cause is scope: run
claude mcp list from the directory you added it in, and if it appears there,
re-add it with --scope user.
Then just ask: "which courses am I in?", "what's due this week?", "find the
material about Verschlüsselung". Inside a session, /mcp lists the servers and
their tools.
Test the CLI
node dist/bin/cli.js ls --long
node dist/bin/cli.js sync --dry-run # shows what it would mirror
node dist/bin/cli.js sync
npm link puts it on your PATH as schulcloud.
Run the test suites
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 (79 checks)
Store tests need a database and skip without one:
docker exec schulcloud-mcp-db psql -U schulcloud -d schulcloud \
-c "CREATE DATABASE schulcloud_test OWNER schulcloud"
TEST_DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_test npm test
These tests TRUNCATE. They refuse to run unless the database name contains
"test", because aiming them at the dev database once was enough — the fixtures
ended up in real data.
Tearing down
docker compose down # keep the index and mirror
docker compose down -v # discard them too
claude mcp remove schulcloud