The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.
Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.
refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.
JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.
Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
schulcloud-mcp
An MCP server that gives Claude read-only access to a Schulcloud account — courses, boards, lessons, tasks — and reads the attached files, so you can ask about your coursework instead of downloading PDFs and uploading them by hand.
Built and verified against schulcloud-thueringen.de with a live student
account. Everything in docs/API.md was confirmed against the running
instance, not inferred from the upstream source.
What Claude can do with it
"What do I have due this week?" "Find the material about Verschlüsselung and explain the Caesar cipher worksheet." "Summarise the routing lesson from the LF10 course."
Thirteen tools, all read-only:
whoami |
account, school, roles — also a connectivity check |
list_courses |
all courses, with ids |
get_dashboard |
the tiles as pinned on the web dashboard |
get_course |
one course's boards, topics and tasks |
get_board |
a column board in full: columns, cards, text, links, files |
get_lesson |
a topic's text sections, materials, files and tasks |
list_tasks |
homework across all courses, by due date |
get_task |
one task: description, due date, status, attachments |
list_files |
files attached to any entity |
download_file |
fetch a file and extract its text, or view an image |
search |
keyword search across courses, boards, files and tasks |
list_news |
school and course announcements |
api_get |
GET-only escape hatch for uncovered API surface |
download_file extracts text from PDF, DOCX, XLSX, PPTX and OpenDocument
files and returns images inline for Claude to look at. Verified against
real files in the account: a 93-file PDF corpus, DOCX, ODT and PPTX all
extract correctly.
Quick start
cp .env.example .env # fill in TSC_URL and TSC_JWT_COOKIE
npm install
npm run build
npm run probe # verifies the token and API against the live instance
Then either deploy it as a remote connector, or point Claude Code at
dist/bin/stdio.js. Both paths are in docs/DEPLOYMENT.md.
Getting TSC_JWT_COOKIE takes four clicks in DevTools. With the server running
it lasts up to 30 days; left idle for two hours it dies regardless — see
docs/AUTH.md.
Design decisions
Bearer token plus a keepalive. The instance's jwt cookie works verbatim
as Authorization: Bearer — no cookie jar, no connect.sid. But the token has
two clocks: a 30-day exp claim, and a 2-hour idle timeout held in a
server-side whitelist that every authenticated request resets. Only the first
is visible in the token, which makes "valid for 30 days" an easy and wrong
conclusion. So the server pings GET /api/v3/me every 30 minutes to hold the
window open. See docs/AUTH.md — this one cost a day-old token to
pin down.
Read-only by construction. Every method on the API client is a GET,
including api_get. The endpoint is internet-facing by necessity (Claude's
connectors call it from Anthropic's cloud), so the fact that a leaked token
cannot be used to act as the user is the main safety property. Adding one
write tool would forfeit it.
Stateless. No database, despite one being available on the host. 26 courses is not a caching problem, and a cache would introduce staleness questions that live calls simply do not have.
Assembled, not raw. get_board makes three kinds of upstream call and
stitches the results — board skeleton, card bodies, and a files-storage lookup
per file element — because a model asking "what's on this board" wants the
answer, not a traversal plan. Output is Markdown with ids preserved for
follow-up calls, not raw JSON.
Layout
src/
bin/ stdio and http entry points
schulcloud/ API client, response types, board assembly
tools/ one module per group of MCP tools
http/ express app, bearer auth
extract.ts document → text
render.ts formatting helpers
docs/ API findings, auth, deployment
deploy/ Caddyfile snippet
scripts/ probe (verify against live) and smoke (end-to-end)
vendor/ upstream clones, git-ignored, for reference only
Development
npm run dev # watch mode, runs src/ directly
npm test # unit tests, no network
npm run probe # check assumptions against the live instance
npm run smoke # full end-to-end: real server, real client, real data
npm run typecheck
npm run smoke starts the HTTP server, connects a real MCP client over
Streamable HTTP and exercises every tool against the live account — 30 checks
covering the auth gate, the protocol handshake, every content chain, file
extraction, api_get's guard rails and error handling.
Upstream
Reference clones live in vendor/ (git-ignored):
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/schulcloud-server.git vendor/schulcloud-server
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/file-storage.git vendor/file-storage
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/nuxt-client.git vendor/nuxt-client
Most of that organisation's ~100 repositories are archived or superseded; those
three are the live ones that matter. The instance's own OpenAPI documents
(/api/v3/docs-json, /api/v3/file/docs-json) are more authoritative than any
of them — see docs/API.md.