Files
Schulcloud-MCP/README.md
MechaCat02 d657ece436 Fix session lifetime: 2h sliding idle timeout, not 30 days
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>
2026-09-12 13:07:22 +02:00

129 lines
5.4 KiB
Markdown

# schulcloud-mcp
An MCP server that gives Claude read-only access to a
[Schulcloud](https://github.com/hpi-schul-cloud) 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
```bash
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](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](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](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
```bash
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):
```bash
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](docs/API.md).