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:
170
scripts/smoke.mjs
Normal file
170
scripts/smoke.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* End-to-end smoke test: starts the HTTP server, connects a real MCP client
|
||||
* over Streamable HTTP, and exercises every tool against the live instance.
|
||||
*
|
||||
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
|
||||
* Read-only — it never writes to Schulcloud.
|
||||
*/
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { createHttpApp } from '../dist/http/server.js';
|
||||
|
||||
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
|
||||
process.env.MCP_AUTH_TOKEN = TOKEN;
|
||||
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
||||
|
||||
const config = loadConfig();
|
||||
const app = createHttpApp(config);
|
||||
const httpServer = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
const { port } = httpServer.address();
|
||||
const base = `http://127.0.0.1:${port}/mcp`;
|
||||
|
||||
let failures = 0;
|
||||
const results = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? ' PASS' : ' FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
// --- auth gate ---------------------------------------------------------
|
||||
console.log('\n== auth ==');
|
||||
{
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
||||
});
|
||||
check('rejects request with no token', res.status === 401, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong-token' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
||||
});
|
||||
check('rejects wrong token', res.status === 401, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
check('healthz is open and ok', res.status === 200);
|
||||
}
|
||||
|
||||
// --- connect -----------------------------------------------------------
|
||||
console.log('\n== protocol ==');
|
||||
const client = new Client({ name: 'smoke', version: '0' }, { capabilities: {} });
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL(base), {
|
||||
requestInit: { headers: { authorization: `Bearer ${TOKEN}` } },
|
||||
}),
|
||||
);
|
||||
check('client connected with valid token', true);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
const names = tools.map((t) => t.name).sort();
|
||||
check('tools listed', tools.length > 0, names.join(', '));
|
||||
check(
|
||||
'every tool has a description and schema',
|
||||
tools.every((t) => t.description && t.inputSchema),
|
||||
);
|
||||
|
||||
const call = async (name, args = {}) => {
|
||||
const res = await client.callTool({ name, arguments: args });
|
||||
const text = res.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
||||
return { res, text, isError: res.isError === true };
|
||||
};
|
||||
|
||||
// --- tools against the live instance -----------------------------------
|
||||
console.log('\n== live tools ==');
|
||||
const who = await call('whoami');
|
||||
check('whoami', !who.isError && /School:/.test(who.text), who.text.split('\n')[0]);
|
||||
|
||||
const courses = await call('list_courses', { limit: 100 });
|
||||
check('list_courses', !courses.isError && /Courses \(/.test(courses.text));
|
||||
const courseIds = [...courses.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
|
||||
check('list_courses returned usable ids', courseIds.length > 0, `${courseIds.length} courses`);
|
||||
|
||||
const active = await call('list_courses', { activeOnly: true });
|
||||
check('list_courses activeOnly', !active.isError);
|
||||
|
||||
const dash = await call('get_dashboard');
|
||||
check('get_dashboard', !dash.isError);
|
||||
|
||||
const tasks = await call('list_tasks', { scope: 'open' });
|
||||
check('list_tasks open', !tasks.isError);
|
||||
const taskId = tasks.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
|
||||
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
|
||||
check('list_news', !(await call('list_news')).isError);
|
||||
|
||||
// Walk courses until we find one with a board, to exercise the whole chain.
|
||||
let boardId, fileId, lessonId, courseWithBoard;
|
||||
for (const id of courseIds) {
|
||||
const course = await call('get_course', { courseId: id });
|
||||
if (course.isError) continue;
|
||||
courseWithBoard ??= id;
|
||||
const b = course.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
const l = course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
lessonId ??= l;
|
||||
if (b && !boardId) boardId = b;
|
||||
if (boardId && lessonId) break;
|
||||
}
|
||||
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
|
||||
check('found a column board', Boolean(boardId), boardId);
|
||||
|
||||
if (boardId) {
|
||||
const board = await call('get_board', { boardId });
|
||||
check('get_board', !board.isError && /Board id:/.test(board.text));
|
||||
fileId = board.text.match(/File: \*\*[^*]+\*\* \(`([0-9a-f]{24})`/)?.[1];
|
||||
check('get_board resolved attachments', Boolean(fileId), fileId ?? 'no files on this board');
|
||||
check('get_board includeFiles=false', !(await call('get_board', { boardId, includeFiles: false })).isError);
|
||||
}
|
||||
|
||||
if (lessonId) check('get_lesson', !(await call('get_lesson', { lessonId })).isError, lessonId);
|
||||
if (taskId) {
|
||||
const task = await call('get_task', { taskId });
|
||||
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
|
||||
}
|
||||
|
||||
// If the board had no file, fall back to hunting one on a task.
|
||||
if (!fileId && taskId) {
|
||||
const listed = await call('list_files', { parentType: 'tasks', parentId: taskId });
|
||||
fileId = listed.text.match(/\(`([0-9a-f]{24})`/)?.[1];
|
||||
}
|
||||
|
||||
if (fileId) {
|
||||
const dl = await call('download_file', { fileId });
|
||||
check('download_file extracts content', !dl.isError && /## /.test(dl.text), dl.text.split('\n').slice(0, 1).join(''));
|
||||
const extracted = /extracted \d+ characters|returned inline|no text extractor/.test(dl.text);
|
||||
check('download_file reported an extraction outcome', extracted);
|
||||
const raw = await call('download_file', { fileId, raw: true });
|
||||
check('download_file raw=true', !raw.isError && /Base64/.test(raw.text));
|
||||
} else {
|
||||
check('download_file', false, 'no file id found to test with');
|
||||
}
|
||||
|
||||
console.log('\n== search ==');
|
||||
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
|
||||
const search = await call('search', { query: searchTerm });
|
||||
check(`search "${searchTerm}"`, !search.isError, search.text.split('\n')[0]);
|
||||
check('search scoped to one course', !(await call('search', { query: 'a b', courseId: courseIds[0] })).isError);
|
||||
|
||||
console.log('\n== api_get guard rails ==');
|
||||
check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError);
|
||||
check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError);
|
||||
check('api_get rejects absolute URL', (await call('api_get', { path: 'https://evil.test/api/x' })).isError);
|
||||
|
||||
console.log('\n== error handling ==');
|
||||
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
|
||||
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
|
||||
|
||||
await client.close();
|
||||
httpServer.close();
|
||||
|
||||
console.log(`\n${results.length - failures}/${results.length} checks passed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user