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:
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { loadConfig } from '../config.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.
|
||||
@@ -9,10 +11,21 @@ async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
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, () => {
|
||||
console.log(
|
||||
`[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) {
|
||||
process.on(signal, () => {
|
||||
console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
|
||||
keepalive?.stop();
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 10_000).unref();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { loadConfig } from '../config.ts';
|
||||
import { SessionKeepalive } from '../keepalive.ts';
|
||||
import { SchulcloudClient } from '../schulcloud/client.ts';
|
||||
import { createServer } from '../server.ts';
|
||||
|
||||
/**
|
||||
@@ -13,6 +15,14 @@ async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const { server } = createServer(config);
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface Config {
|
||||
/** Characters of extracted text returned before truncation kicks in. */
|
||||
maxExtractedChars: 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 {
|
||||
@@ -38,6 +44,17 @@ function int(name: string, fallback: number): number {
|
||||
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 {
|
||||
return {
|
||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||
@@ -48,5 +65,6 @@ export function loadConfig(): Config {
|
||||
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
||||
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
|
||||
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
|
||||
keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import type { MeResponse } from './schulcloud/types.ts';
|
||||
* cannot change for a given JWT. Everything else is fetched live.
|
||||
*/
|
||||
export class ServerContext {
|
||||
readonly config: Config;
|
||||
readonly client: SchulcloudClient;
|
||||
private identity: Promise<MeResponse> | undefined;
|
||||
|
||||
constructor(readonly config: Config) {
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.client = new SchulcloudClient(config);
|
||||
}
|
||||
|
||||
|
||||
88
src/keepalive.ts
Normal file
88
src/keepalive.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,16 @@ import type {
|
||||
|
||||
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
|
||||
export class SchulcloudApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly path: string,
|
||||
readonly body: string,
|
||||
) {
|
||||
readonly status: number;
|
||||
readonly path: string;
|
||||
readonly body: string;
|
||||
|
||||
constructor(status: number, path: string, body: string) {
|
||||
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
|
||||
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. */
|
||||
@@ -58,7 +61,11 @@ export interface DownloadedFile {
|
||||
* read this account's data but cannot act as the user inside Schulcloud.
|
||||
*/
|
||||
export class SchulcloudClient {
|
||||
constructor(private readonly config: Config) {}
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// --- transport -------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user