Initial schulcloud-mcp server
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>
This commit is contained in:
114
scripts/probe.mjs
Normal file
114
scripts/probe.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verifies this server's assumptions against the live instance and prints what
|
||||
* it finds. Run it after a Schulcloud release, or when a tool starts failing,
|
||||
* to tell "the token expired" apart from "the API moved".
|
||||
*
|
||||
* Read-only. Usage: `node --env-file=.env scripts/probe.mjs`
|
||||
*/
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const client = new SchulcloudClient(config);
|
||||
|
||||
console.log(`instance: ${config.baseUrl}\n`);
|
||||
|
||||
// --- token ---------------------------------------------------------------
|
||||
const payload = decodeJwt(config.jwt);
|
||||
if (payload) {
|
||||
const expires = new Date(payload.exp * 1000);
|
||||
const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000;
|
||||
console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 10)}, ` +
|
||||
`expires ${expires.toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`);
|
||||
if (daysLeft < 0) console.log(' *** EXPIRED — 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 {
|
||||
console.log('token: could not decode (not a JWT?)');
|
||||
}
|
||||
|
||||
// --- endpoints this server depends on ------------------------------------
|
||||
const checks = [
|
||||
['GET /api/v3/me', () => client.me()],
|
||||
['GET /api/v3/courses', () => client.listCourses({ limit: 1 })],
|
||||
['GET /api/v3/dashboard', () => client.getDashboard()],
|
||||
['GET /api/v3/tasks', () => client.listTasks({ limit: 1 })],
|
||||
['GET /api/v3/tasks/finished', () => client.listFinishedTasks({ limit: 1 })],
|
||||
['GET /api/v3/news', () => client.listNews({ limit: 1 })],
|
||||
['GET /api/v3/docs-json', () => client.getJson('/api/v3/docs-json')],
|
||||
['GET /api/v3/file/docs-json', () => client.getJson('/api/v3/file/docs-json')],
|
||||
];
|
||||
|
||||
console.log('\ncore endpoints:');
|
||||
let me;
|
||||
for (const [label, run] of checks) {
|
||||
try {
|
||||
const result = await run();
|
||||
if (label.endsWith('/me')) me = result;
|
||||
console.log(` ok ${label}${summarize(result)}`);
|
||||
} catch (error) {
|
||||
const status = error instanceof SchulcloudApiError ? error.status : '—';
|
||||
console.log(` FAIL ${label} → ${status} ${error.message.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- the chains that make the content tools work -------------------------
|
||||
if (me) {
|
||||
console.log('\ncontent chain:');
|
||||
const courses = await client.listAllCourses();
|
||||
console.log(` ${courses.length} course(s) visible`);
|
||||
|
||||
let boardId, lessonId;
|
||||
for (const course of courses) {
|
||||
const page = await client.getCourseBoard(course.id).catch(() => null);
|
||||
if (!page) continue;
|
||||
boardId ??= page.elements.find((e) => e.type === 'column-board')?.content.id;
|
||||
lessonId ??= page.elements.find((e) => e.type === 'lesson')?.content.id;
|
||||
if (boardId && lessonId) break;
|
||||
}
|
||||
|
||||
if (boardId) {
|
||||
const skeleton = await client.getBoardSkeleton(boardId);
|
||||
const cardIds = skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId));
|
||||
const cards = await client.getCards(cardIds.slice(0, 5));
|
||||
console.log(` ok board → columns → cards (board ${boardId}: ${skeleton.columns.length} col, ${cardIds.length} cards)`);
|
||||
|
||||
const fileElement = cards.flatMap((c) => c.elements).find((e) => e.type === 'file');
|
||||
if (fileElement) {
|
||||
const files = await client.listFiles({
|
||||
storageLocationId: me.school.id,
|
||||
parentType: 'boardnodes',
|
||||
parentId: fileElement.id,
|
||||
});
|
||||
console.log(` ok file element → files-storage (${files.total} file(s) on element ${fileElement.id})`);
|
||||
} else {
|
||||
console.log(' — no file element among the sampled cards');
|
||||
}
|
||||
} else {
|
||||
console.log(' — no column board found to test with');
|
||||
}
|
||||
|
||||
if (lessonId) {
|
||||
const lesson = await client.getLesson(lessonId);
|
||||
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(result) {
|
||||
if (result && typeof result === 'object') {
|
||||
if ('total' in result) return ` (total ${result.total})`;
|
||||
if ('paths' in result) return ` (${Object.keys(result.paths).length} paths)`;
|
||||
if ('school' in result) return ` (${result.school.name})`;
|
||||
if ('gridElements' in result) return ` (${result.gridElements.length} tiles)`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user