The endurance test refuted the sliding-window model I committed earlier. A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was still rejected by t+120 — consistent with the session ending ~2h after LOGIN (t+107), and inconsistent with 2h after the last request, which would have been t+210. This is a live-vs-source divergence, not a misreading: both the current JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted re-set the Valkey TTL on every authenticated request, so the source reads as a sliding window. The instance does not behave that way. So the keepalive now calls POST /authentication/refresh-session, the endpoint behind the UI's "Sitzung verlängern" button, which a separate 100s test showed does hold the reported budget at 7200s. It is the only non-GET request in the server: no body, touches only our own session, cannot read or modify user data, and is not exposed as a tool, so no model-driven call can ever be a POST. It logs the returned budget, which makes a failing extension visible before the session is lost. Whether this is sufficient is NOT established. Two mechanisms still fit: an idle TTL that reads fail to refresh (keepalive works), or an absolute cap/revocation anchored at login — e.g. the IDP's back-channel logout, which clears every token for the account rather than one. Added scripts/session-diagnose.mjs to settle it: it logs the budget every 10 min, so a decaying series indicates the former and an abrupt 401 at 7200s the latter. Docs state the open question rather than asserting a mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.6 KiB
Markdown
131 lines
5.6 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. Expect to redo it
|
|
often until the session question in [docs/AUTH.md](docs/AUTH.md) is settled —
|
|
sessions have been observed ending ~2 h after login.
|
|
|
|
## 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's
|
|
`exp` claim (30 days) is not its lifetime: the session ends about **two hours
|
|
after login**, and measurement showed that ordinary API reads do *not* extend
|
|
it, despite the upstream source saying they should. So the server calls
|
|
`refresh-session` every 30 minutes — the one non-GET request here, and not
|
|
exposed as a tool. Whether that is enough is still being measured; see
|
|
[docs/AUTH.md](docs/AUTH.md), which has the endurance test and the open
|
|
question.
|
|
|
|
**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 session-diagnose # instrument what actually ends the session (~2.5h)
|
|
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).
|