Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.
The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:
- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
listing files-storage with parentType=boardnodes and the element id.
Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.
Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.
Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
51 lines
1.9 KiB
TypeScript
51 lines
1.9 KiB
TypeScript
import { timingSafeEqual } from 'node:crypto';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
|
|
/**
|
|
* Bearer-token gate for the public endpoint.
|
|
*
|
|
* This server is reachable from the internet by construction — Claude's
|
|
* connectors call it from Anthropic's cloud, not from the user's machine — so
|
|
* the token is the only thing between a stranger and the account's data.
|
|
* Comparison is constant-time, and a miss returns a bare 401 with a
|
|
* `WWW-Authenticate` challenge and no detail about why.
|
|
*/
|
|
export function bearerAuth(expected: string) {
|
|
const expectedBytes = Buffer.from(expected, 'utf8');
|
|
|
|
return function authenticate(req: Request, res: Response, next: NextFunction): void {
|
|
const presented = extractToken(req.get('authorization'), req.get('x-api-key'));
|
|
if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
|
|
res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"');
|
|
res.status(401).json({
|
|
jsonrpc: '2.0',
|
|
error: { code: -32001, message: 'Unauthorized' },
|
|
id: null,
|
|
});
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
|
|
if (authorization) {
|
|
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
if (match?.[1]) return match[1].trim();
|
|
}
|
|
// Some connector UIs only offer a custom header rather than Authorization.
|
|
return apiKey?.trim() || undefined;
|
|
}
|
|
|
|
function constantTimeEquals(a: Buffer, b: Buffer): boolean {
|
|
// timingSafeEqual throws on length mismatch, which would itself leak length.
|
|
// Hash-free equalisation: compare against a padded copy of the same size.
|
|
if (a.length !== b.length) {
|
|
const padded = Buffer.alloc(b.length);
|
|
a.copy(padded, 0, 0, Math.min(a.length, b.length));
|
|
timingSafeEqual(padded, b);
|
|
return false;
|
|
}
|
|
return timingSafeEqual(a, b);
|
|
}
|