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>
This commit is contained in:
2026-09-12 13:07:22 +02:00
parent 35125b7683
commit d657ece436
14 changed files with 408 additions and 57 deletions

View File

@@ -6,7 +6,9 @@
TSC_URL=https://schulcloud-thueringen.de TSC_URL=https://schulcloud-thueringen.de
# The value of the `jwt` cookie from a logged-in browser session. # The value of the `jwt` cookie from a logged-in browser session.
# Valid for 30 days from issue; see docs/AUTH.md for how to copy a fresh one. # Two clocks apply: a 30-day hard expiry, and a 2-hour idle timeout that every
# API call resets. The built-in keepalive handles the second one, so in practice
# this needs replacing monthly. See docs/AUTH.md.
TSC_JWT_COOKIE= TSC_JWT_COOKIE=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -36,3 +38,9 @@ BIND_HOST=0.0.0.0
# Per-request timeout against the Schulcloud API, in ms. Default 30000. # Per-request timeout against the Schulcloud API, in ms. Default 30000.
# REQUEST_TIMEOUT_MS=30000 # REQUEST_TIMEOUT_MS=30000
# How often to ping Schulcloud to hold the session open, in ms. Default 1800000
# (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS — 7200s
# here, readable from GET /api/v3/config/public. Set to 0 to disable, which
# will let the token die after two hours of inactivity.
# KEEPALIVE_INTERVAL_MS=1800000

View File

@@ -59,7 +59,7 @@ the property that makes that acceptable. Do not add a write tool without the
user explicitly asking for one and understanding this. user explicitly asking for one and understanding this.
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
account for 30 days; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
logs, error messages, or tool output. `.env` is git-ignored — keep it that way. logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
**Live behaviour beats upstream source.** The clones in `vendor/` track `main` **Live behaviour beats upstream source.** The clones in `vendor/` track `main`
@@ -84,11 +84,18 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
its own OpenAPI document. It is not in the main `docs-json`. its own OpenAPI document. It is not in the main `docs-json`.
- Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use - Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use
`normalizeObjectId`. `normalizeObjectId`.
- **The JWT dies after 2h idle, not 30 days.** `exp` is a hard ceiling; the
real limit is a Valkey whitelist entry (`JWT_TIMEOUT_SECONDS`, live value at
`GET /api/v3/config/public`) that every authenticated request re-sets.
`src/keepalive.ts` holds it open. Do not "simplify" it away.
## Conventions ## Conventions
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc` - Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly. emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly.
- **No TypeScript parameter properties** (`constructor(private readonly x: T)`).
Node's type stripping rejects them, which breaks `npm run dev` and `npm test`.
Declare the field and assign it in the constructor body instead.
- Tabs for indentation, single quotes, trailing commas. - Tabs for indentation, single quotes, trailing commas.
- Comments explain *why* — an API quirk, a security property, a trade-off — not - Comments explain *why* — an API quirk, a security property, a trade-off — not
what the line does. Several such comments encode findings that are expensive what the line does. Several such comments encode findings that are expensive
@@ -108,5 +115,5 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
## Environment ## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example` `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
for the full set and `docs/AUTH.md` for refreshing the JWT — it expires every 30 for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe`
days, and `npm run probe` reports the days remaining. reports both clocks: days until hard expiry and seconds of idle budget left.

View File

@@ -50,15 +50,20 @@ npm run probe # verifies the token and API against the live instan
Then either deploy it as a remote connector, or point Claude Code at 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). `dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and lasts 30 days — Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. With the server running
see [docs/AUTH.md](docs/AUTH.md). it lasts up to 30 days; left idle for two hours it dies regardless — see
[docs/AUTH.md](docs/AUTH.md).
## Design decisions ## Design decisions
**Bearer token, not a cookie jar.** The instance's `jwt` cookie works verbatim **Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim
as `Authorization: Bearer`, and is valid for 30 days. There is no session to as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token has
keep alive and no `refresh-session` timer — a simplification that only became two clocks: a 30-day `exp` claim, and a **2-hour idle timeout** held in a
apparent by testing against the live instance. 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`, **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 including `api_get`. The endpoint is internet-facing by necessity (Claude's

View File

@@ -79,6 +79,8 @@ current material**; lessons are the older format.
| One file's metadata | `GET /api/v3/file/{fileRecordId}` | | One file's metadata | `GET /api/v3/file/{fileRecordId}` |
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` | | File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
| News | `GET /api/v3/news` | | News | `GET /api/v3/news` |
| Instance settings (no auth) | `GET /api/v3/config/public` |
| Remaining idle budget | `POST /api/v3/authentication/refresh-session``{expiresInSeconds}` |
### Gotchas that cost real time ### Gotchas that cost real time
@@ -115,6 +117,16 @@ ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts` from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
converts them. converts them.
**The JWT's `exp` is not the session lifetime.** A server-side whitelist entry
in Valkey (`jwt:{accountId}:{jti}`) expires after `JWT_TIMEOUT_SECONDS` — 7200 s
on this instance — and every authenticated request re-sets it. Two hours idle
and the token is rejected with 29 days still on `exp`. The live values are
public at `GET /api/v3/config/public`. Full write-up in `docs/AUTH.md`.
**`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of
instance configuration, including the session timeouts and feature flags. Handy
for checking deployed settings without a token.
**`Content-Disposition` on downloads is malformed.** It comes back as **`Content-Disposition` on downloads is malformed.** It comes back as
`attachment;; filename="…"` — note the doubled semicolon — and the filename is `attachment;; filename="…"` — note the doubled semicolon — and the filename is
percent-encoded inside the quotes. Parse defensively. percent-encoded inside the quotes. Parse defensively.
@@ -132,4 +144,5 @@ the Etherpad-style editor, not the document text.
## Re-verifying after an upstream release ## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and `npm run probe` re-checks every assumption above against the live instance and
prints what it finds, including days left on the token. prints what it finds — both token clocks included: days until hard expiry, and
seconds of idle budget remaining.

View File

@@ -14,44 +14,106 @@ Cookie: jwt=<jwt> → 200 # also works
(no auth) → 401 (no auth) → 401
``` ```
`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed. There is no `connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed — there is no
cookie jar, no session to keep alive, and no `refresh-session` call on a timer. cookie jar. There *is* a session to keep alive, though not in the way the
cookies suggest; see the next section.
## Token lifetime: 30 days ## Token lifetime: two clocks, and the short one is the one that bites
The token is a standard JWT. Decoded from the live instance: This is the part that is easy to get wrong, because the JWT lies to you by
omission. Two independent limits govern the token:
``` | Clock | Value | Extendable? |
iss / aud : schulcloud-thueringen.de |---|---|---|
iat → exp : 720 hours (exactly 30 days) | **Idle timeout** — a whitelist entry in the server's Valkey store | **7200 s (2 hours)** | Yes — reset by *every* authenticated request |
claims : accountId, userId, schoolId, roles, systemId, jti, | **Hard expiry** — the JWT's own `exp` claim | **30 days** | No |
isExternalUser, isServiceAccount, support
The token dies at whichever comes first. Decoding the JWT shows only the second
one, which is how "valid for 30 days" becomes a plausible and wrong conclusion.
### How the idle timeout works
`JwtStrategy.validate()` runs on every request behind `@JwtAuthentication()`
and calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`. That method:
1. reads Valkey key `jwt:{accountId}:{jti}`; if it is gone, throws
`UnauthorizedException('Session was expired due to inactivity - autologout.')`;
2. **re-sets the key with a fresh `JWT_TIMEOUT_SECONDS` TTL.**
Step 2 is the whole mechanism. The window *slides*: any successful API call
buys another two hours. Two hours of silence and the token is gone, with 29
days still left on `exp`.
Source: `apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts` and
`apps/server/src/infra/jwt-whitelist/adapter/jwt-whitelist.adapter.ts`.
### The instance publishes these values
Unauthenticated, so you can check them any time:
```bash
curl -s "$TSC_URL/api/v3/config/public" | jq '{JWT_TIMEOUT_SECONDS, JWT_SHOW_TIMEOUT_WARNING_SECONDS}'
# { "JWT_TIMEOUT_SECONDS": 7200, "JWT_SHOW_TIMEOUT_WARNING_SECONDS": 3600 }
``` ```
So a token copied today works for a month, and refreshing it is a calendar `JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600` is exactly the web UI's behaviour: it
chore rather than an engineering problem. `npm run probe` prints the days warns when 3600 s of the 7200 s budget remain — i.e. after one hour of
remaining. inactivity — and offers "Sitzung verlängern". Missing that prompt really does
log you out; it is not just the frontend discarding the token.
## Getting a fresh token ### Verified empirically
1. Log in to the instance in a normal browser. A token issued 2026-09-11T21:12Z was last used at ~21:47Z. At 2026-09-12T11:00Z
2. DevTools → **Application****Cookies** → the instance's origin. — 13.8 hours later, with 29 days left on `exp``GET /api/v3/me` returned
3. Copy the value of the **`jwt`** cookie. `401`. Consistent with the 2-hour idle timeout, and decisive against the
4. Put it in `TSC_JWT_COOKIE` in `.env` and restart the server 30-day reading.
(`docker compose restart schulcloud-mcp`).
There is no need to log out afterwards; the token stays valid independently of ### `refresh-session` is not special
the browser session.
## How you will know it expired `POST /api/v3/authentication/refresh-session` is what the "Sitzung verlängern"
button calls, but it carries `@JwtAuthentication()` like every other route, so
the extension is a side effect of the guard — the same side effect a plain
`GET /api/v3/me` produces. Its handler only calls `getJwtTtlFromWhitelist` and
returns `{ expiresInSeconds }`.
Every tool returns a specific message on `401` rather than a generic failure: That makes it useful as a **measuring instrument** rather than a necessity:
it is the only way to read how much idle budget is left. `npm run probe`
reports it. It is also the only non-`GET` call anywhere in this repository, and
it lives in that diagnostic script rather than in the server.
> Schulcloud rejected the token … The JWT in TSC_JWT_COOKIE has expired or been ### There is no longer window available
> revoked.
That message is the signal to redo the four steps above. A `403` means the `config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
account genuinely lacks access to that resource and is *not* a token problem. (2629746 s ≈ 1 month), reachable in the legacy stack via a `privateDevice`
login flag. On the current NestJS server that is vestigial:
`privateDevice` has no references in `apps/server/src`, and
`generateJwtAndAddToWhitelist` calls `addToWhitelist(accountId, jti)` with no
TTL override, so every token gets `jwtTimeoutSeconds`. A "remember me" login
will not buy a longer idle window.
## How this server stays alive
`src/keepalive.ts` pings `GET /api/v3/me` every 30 minutes (configurable via
`KEEPALIVE_INTERVAL_MS`; `0` disables it). Started by both entry points,
independent of whether any MCP client is connected — the token expires on wall
time, not on usage.
Thirty minutes against a 7200 s budget tolerates three consecutive failures
before the session is at risk. A transient failure retries in 5 minutes; a
`401` stops the keepalive permanently and logs what to do, because a lapsed
whitelist entry cannot be revived by retrying — only by pasting a new token.
**Operational consequence worth knowing:** if the container is down for more
than two hours — a long power cut, a Pi left off overnight — the token is dead
when it comes back, and restarting will not fix it. The startup log says so
immediately:
```
[schulcloud-mcp] keepalive: token rejected (401). The session has expired …
```
With the keepalive running, a token survives up to its 30-day hard expiry, at
which point it must be replaced by hand regardless.
## Why not username + password ## Why not username + password
@@ -62,9 +124,9 @@ cannot replicate that flow. `POST /api/v3/authentication/local` exists but is
for accounts with local credentials, which federated school accounts do not for accounts with local credentials, which federated school accounts do not
have. have.
Given a 30-day token, the pasted-JWT approach is the right trade: one manual Given a keepalive that holds a token to its 30-day ceiling, the pasted-JWT
step a month against re-implementing an OAuth client we cannot hold the secret approach is still the right trade: one manual step a month against
for. If this ever needs to be unattended, the honest options are a service re-implementing an OAuth client we cannot hold the secret for. If this ever needs to be unattended, the honest options are a service
account issued by the school's IDM, or a headless browser login — not a account issued by the school's IDM, or a headless browser login — not a
reimplementation of the Keycloak exchange. reimplementation of the Keycloak exchange.

View File

@@ -35,11 +35,13 @@ docker compose logs -f schulcloud-mcp
Expect: Expect:
``` ```
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled [schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled, keepalive every 30min
``` ```
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing `auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
the service. the service. A `keepalive: token rejected (401)` line right after startup means
the Schulcloud token is dead and needs replacing (see docs/AUTH.md); the server
will run but every tool will fail.
## Joining the existing Caddy ## Joining the existing Caddy
@@ -153,5 +155,11 @@ npm run probe # re-verify the API assumptions
`no-new-privileges`, running as the unprivileged `node` user. It writes `no-new-privileges`, running as the unprivileged `node` user. It writes
nothing to disk — downloads are streamed through memory, capped at nothing to disk — downloads are streamed through memory, capped at
`MAX_DOWNLOAD_BYTES` (25 MiB default). `MAX_DOWNLOAD_BYTES` (25 MiB default).
- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how - **The Schulcloud session dies after 2 hours of inactivity**, so the server
many days are left. pings `/api/v3/me` every 30 minutes to hold it open. This has a consequence
worth planning for: **downtime longer than two hours kills the token**, and
restarting does not recover it — a long power cut means pasting a fresh
`TSC_JWT_COOKIE`. The startup log says so immediately. Background in
docs/AUTH.md.
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
`npm run probe` reports both clocks.

View File

@@ -15,18 +15,50 @@ const client = new SchulcloudClient(config);
console.log(`instance: ${config.baseUrl}\n`); console.log(`instance: ${config.baseUrl}\n`);
// --- token --------------------------------------------------------------- // --- token ---------------------------------------------------------------
// Two independent clocks govern the token, and only one of them is in the JWT:
// exp — a hard 30-day ceiling, cannot be extended.
// whitelist TTL — JWT_TIMEOUT_SECONDS (2h here), reset by every request.
// The second one is what actually kills idle sessions, so report both.
const payload = decodeJwt(config.jwt); const payload = decodeJwt(config.jwt);
if (payload) { if (payload) {
const expires = new Date(payload.exp * 1000);
const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000; const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000;
console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 10)}, ` + console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 16)}Z, ` +
`expires ${expires.toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`); `hard expiry ${new Date(payload.exp * 1000).toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`);
if (daysLeft < 0) console.log(' *** EXPIRED — copy a fresh jwt cookie, see docs/AUTH.md'); if (daysLeft < 0) console.log(' *** PAST HARD EXPIRY — copy a fresh jwt cookie, see docs/AUTH.md');
else if (daysLeft < 5) console.log(' *** expiring soon — plan to copy a fresh jwt cookie'); else if (daysLeft < 5) console.log(' *** hard expiry approaching — plan to copy a fresh jwt cookie');
} else { } else {
console.log('token: could not decode (not a JWT?)'); console.log('token: could not decode (not a JWT?)');
} }
// The instance publishes its own session settings, unauthenticated.
try {
const publicConfig = await client.getJson('/api/v3/config/public');
console.log(`instance: JWT_TIMEOUT_SECONDS=${publicConfig.JWT_TIMEOUT_SECONDS} ` +
`(idle timeout), warning shown at ${publicConfig.JWT_SHOW_TIMEOUT_WARNING_SECONDS}s remaining`);
} catch {
console.log('instance: could not read /api/v3/config/public');
}
// refresh-session reports the whitelist TTL. It is the one non-GET call in this
// repo, and it lives here in a diagnostic rather than in the server, whose every
// upstream call is a GET. It extends the session no more than any GET does.
try {
const response = await fetch(`${config.baseUrl}/api/v3/authentication/refresh-session`, {
method: 'POST',
headers: { Authorization: `Bearer ${config.jwt}`, 'Content-Length': '0' },
});
if (response.ok) {
const { expiresInSeconds } = await response.json();
console.log(`session: ${expiresInSeconds}s of idle budget left ` +
`(${(expiresInSeconds / 60).toFixed(0)} min) — any request resets this`);
} else {
console.log(`session: refresh-session returned ${response.status}` +
(response.status === 401 ? ' *** session expired through inactivity or hard expiry' : ''));
}
} catch (error) {
console.log(`session: could not read TTL (${error.message})`);
}
// --- endpoints this server depends on ------------------------------------ // --- endpoints this server depends on ------------------------------------
const checks = [ const checks = [
['GET /api/v3/me', () => client.me()], ['GET /api/v3/me', () => client.me()],

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import { loadConfig } from '../config.ts'; import { loadConfig } from '../config.ts';
import { createHttpApp } from '../http/server.ts'; import { createHttpApp } from '../http/server.ts';
import { SessionKeepalive } from '../keepalive.ts';
import { SchulcloudClient } from '../schulcloud/client.ts';
/** /**
* HTTP entry point — the deployed form of this server, sitting behind Caddy. * HTTP entry point — the deployed form of this server, sitting behind Caddy.
@@ -9,10 +11,21 @@ async function main(): Promise<void> {
const config = loadConfig(); const config = loadConfig();
const app = createHttpApp(config); const app = createHttpApp(config);
// One process-wide keepalive, independent of MCP sessions: the Schulcloud
// token dies after 2h of inactivity regardless of whether anyone is connected.
const keepalive =
config.keepaliveIntervalMs > 0
? new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs, undefined, (message) =>
console.log(message),
)
: undefined;
keepalive?.start();
const server = app.listen(config.port, config.bindHost, () => { const server = app.listen(config.port, config.bindHost, () => {
console.log( console.log(
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` + `[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}`, `auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}`,
); );
}); });
@@ -20,6 +33,7 @@ async function main(): Promise<void> {
for (const signal of ['SIGTERM', 'SIGINT'] as const) { for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => { process.on(signal, () => {
console.log(`[schulcloud-mcp] ${signal} received, shutting down`); console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
keepalive?.stop();
server.close(() => process.exit(0)); server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 10_000).unref(); setTimeout(() => process.exit(0), 10_000).unref();
}); });

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { loadConfig } from '../config.ts'; import { loadConfig } from '../config.ts';
import { SessionKeepalive } from '../keepalive.ts';
import { SchulcloudClient } from '../schulcloud/client.ts';
import { createServer } from '../server.ts'; import { createServer } from '../server.ts';
/** /**
@@ -13,6 +15,14 @@ async function main(): Promise<void> {
const config = loadConfig(); const config = loadConfig();
const { server } = createServer(config); const { server } = createServer(config);
await server.connect(new StdioServerTransport()); await server.connect(new StdioServerTransport());
// A desktop client left open overnight idles far past the instance's 2h
// session timeout, so stdio needs the keepalive just as much as HTTP does.
// It logs to stderr; stdout carries protocol frames only.
if (config.keepaliveIntervalMs > 0) {
new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs).start();
}
console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`); console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`);
} }

View File

@@ -20,6 +20,12 @@ export interface Config {
/** Characters of extracted text returned before truncation kicks in. */ /** Characters of extracted text returned before truncation kicks in. */
maxExtractedChars: number; maxExtractedChars: number;
requestTimeoutMs: number; requestTimeoutMs: number;
/**
* How often to ping the instance to hold the session open. Must stay well
* under the instance's `JWT_TIMEOUT_SECONDS` (7200s here) — see
* src/keepalive.ts. Zero disables the keepalive.
*/
keepaliveIntervalMs: number;
} }
function required(name: string): string { function required(name: string): string {
@@ -38,6 +44,17 @@ function int(name: string, fallback: number): number {
return parsed; return parsed;
} }
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
function intAllowingZero(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`Environment variable ${name} must be a non-negative integer, got ${raw}`);
}
return parsed;
}
export function loadConfig(): Config { export function loadConfig(): Config {
return { return {
baseUrl: required('TSC_URL').replace(/\/+$/, ''), baseUrl: required('TSC_URL').replace(/\/+$/, ''),
@@ -48,5 +65,6 @@ export function loadConfig(): Config {
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024), maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000), maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000), requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000),
}; };
} }

View File

@@ -10,10 +10,12 @@ import type { MeResponse } from './schulcloud/types.ts';
* cannot change for a given JWT. Everything else is fetched live. * cannot change for a given JWT. Everything else is fetched live.
*/ */
export class ServerContext { export class ServerContext {
readonly config: Config;
readonly client: SchulcloudClient; readonly client: SchulcloudClient;
private identity: Promise<MeResponse> | undefined; private identity: Promise<MeResponse> | undefined;
constructor(readonly config: Config) { constructor(config: Config) {
this.config = config;
this.client = new SchulcloudClient(config); this.client = new SchulcloudClient(config);
} }

88
src/keepalive.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { SchulcloudClient } from './schulcloud/client.ts';
import { SchulcloudApiError } from './schulcloud/client.ts';
/**
* Keeps the Schulcloud session alive.
*
* The JWT's `exp` claim says 30 days, but that is only an outer ceiling. The
* server also keeps a whitelist entry per token in Valkey, keyed
* `jwt:{accountId}:{jti}`, whose TTL is `JWT_TIMEOUT_SECONDS` — 7200s (2h) on
* this instance, readable from `GET /api/v3/config/public`. Every request that
* passes the JWT guard re-sets that key, so the window slides; let it lapse
* and the token is rejected with 401 "Session was expired due to inactivity",
* long before `exp`.
*
* So an idle server loses its token overnight. Pinging any authenticated
* endpoint is enough to hold it: `POST /authentication/refresh-session` is
* what the web UI's "Sitzung verlängern" button calls, but it extends the
* session through the very same guard as every other route, and additionally
* reports the remaining TTL. We use a plain `GET /api/v3/me` instead, so that
* every call this server makes upstream remains a GET.
*/
export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined;
private stopped = false;
private readonly client: SchulcloudClient;
private readonly intervalMs: number;
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
private readonly retryMs: number;
private readonly log: (message: string) => void;
constructor(
client: SchulcloudClient,
intervalMs: number,
retryMs: number = Math.min(5 * 60_000, intervalMs),
log: (message: string) => void = (message) => console.error(message),
) {
this.client = client;
this.intervalMs = intervalMs;
this.retryMs = retryMs;
this.log = log;
}
/** Pings once now (validating the token at startup), then on the interval. */
start(): void {
this.stopped = false;
void this.tick();
}
stop(): void {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
}
private schedule(delayMs: number): void {
if (this.stopped) return;
this.timer = setTimeout(() => void this.tick(), delayMs);
// Never hold the process open just for a keepalive.
this.timer.unref();
}
private async tick(): Promise<void> {
if (this.stopped) return;
try {
await this.client.me();
this.schedule(this.intervalMs);
} catch (error) {
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
// ceiling. Pinging harder cannot revive it — a human must paste a new
// token — so stop and say so loudly rather than logging every 30 min.
this.log(
'[schulcloud-mcp] keepalive: token rejected (401). The session has expired — ' +
'either more than 2h elapsed without a successful request, or the JWT reached ' +
'its 30-day limit. Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. ' +
'Keepalive stopped.',
);
this.stop();
return;
}
this.log(
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
`retrying in ${Math.round(this.retryMs / 1000)}s`,
);
this.schedule(this.retryMs);
}
}
}

View File

@@ -17,13 +17,16 @@ import type {
/** An API response outside the 2xx range, carrying the status for callers to branch on. */ /** An API response outside the 2xx range, carrying the status for callers to branch on. */
export class SchulcloudApiError extends Error { export class SchulcloudApiError extends Error {
constructor( readonly status: number;
readonly status: number, readonly path: string;
readonly path: string, readonly body: string;
readonly body: string,
) { constructor(status: number, path: string, body: string) {
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`); super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
this.name = 'SchulcloudApiError'; this.name = 'SchulcloudApiError';
this.status = status;
this.path = path;
this.body = body;
} }
/** True when the instance rejected our JWT — the one error the user must act on. */ /** True when the instance rejected our JWT — the one error the user must act on. */
@@ -58,7 +61,11 @@ export interface DownloadedFile {
* read this account's data but cannot act as the user inside Schulcloud. * read this account's data but cannot act as the user inside Schulcloud.
*/ */
export class SchulcloudClient { export class SchulcloudClient {
constructor(private readonly config: Config) {} private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
// --- transport ------------------------------------------------------- // --- transport -------------------------------------------------------

77
test/keepalive.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { SchulcloudApiError } from '../src/schulcloud/client.ts';
import { SessionKeepalive } from '../src/keepalive.ts';
/** A stand-in for SchulcloudClient that records calls and replays scripted outcomes. */
function fakeClient(outcomes: (Error | 'ok')[]) {
const calls: number[] = [];
return {
calls,
client: {
me: async () => {
const outcome = outcomes[calls.length] ?? 'ok';
calls.push(Date.now());
if (outcome !== 'ok') throw outcome;
return {} as never;
},
} as never,
};
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
describe('SessionKeepalive', () => {
it('pings immediately on start, so a bad token is noticed at boot', async () => {
const { client, calls } = fakeClient(['ok']);
const keepalive = new SessionKeepalive(client, 60_000, 1000, () => {});
keepalive.start();
await settle();
assert.equal(calls.length, 1);
keepalive.stop();
});
it('keeps pinging on the interval', async () => {
const { client, calls } = fakeClient([]);
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 120));
keepalive.stop();
assert.ok(calls.length >= 3, `expected repeated pings, got ${calls.length}`);
});
it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => {
const unauthorized = new SchulcloudApiError(401, '/api/v3/me', '');
const { client, calls } = fakeClient([unauthorized]);
const messages: string[] = [];
const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message));
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 120));
assert.equal(calls.length, 1, 'must not keep hammering a dead token');
assert.equal(messages.length, 1);
assert.match(messages[0]!, /fresh jwt cookie/);
keepalive.stop();
});
it('retries a transient failure instead of giving up', async () => {
const { client, calls } = fakeClient([new Error('ECONNRESET')]);
const messages: string[] = [];
const keepalive = new SessionKeepalive(client, 10_000, 15, (message) => messages.push(message));
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 120));
keepalive.stop();
assert.ok(calls.length >= 2, `expected a retry, got ${calls.length} call(s)`);
assert.match(messages[0]!, /retrying in/);
});
it('stop() prevents any further pings', async () => {
const { client, calls } = fakeClient([]);
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
keepalive.start();
await settle();
keepalive.stop();
const seen = calls.length;
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(calls.length, seen, 'no pings after stop()');
});
});