Files
Schulcloud-MCP/scripts/smoke.mjs
MechaCat02 521c21f7ae Reach tasks attached to topics, and read Etherpad pads
Testing against a local instance turned up four things the server was
getting wrong, all of them invisible against the live account because the
data that exposes them had never been produced there.

`GET /lessons/{id}/tasks` returns a bare array, not the `{data,total}`
envelope every sibling endpoint uses, so `.data` was undefined and a
topic's tasks silently vanished. Its items also carry no id at all —
`LessonLinkedTaskResponse` has no id property — which leaves a
topic-attached task unidentifiable: it is not a task element on the
course page, and once past due it is in neither task list. So its
submission, and its grade, could not be reached by any route. That is 18
of 60 tasks on the real account, now reachable: the ids come off the
legacy topic page, where each task is linked as `/homework/{id}`.

The types said `id: string` and `status: TaskStatus` on something that
has neither, which is what let this stay quiet; `LessonLinkedTask` and
`ResolvedTask` now say what is actually there.

Collaborative text editor elements come back with `content: {}`, and the
tool said their contents were unavailable. They are available: the
content-element endpoint returns the pad url *and* an Etherpad session
cookie, and the pad exports itself as text to whoever holds it. No API
key needed. Pads are now shown by get_board and indexed for search.

The store's file digest covered id and size on the grounds that file
records are immutable. `PATCH /file/rename/{id}` renames one in place,
so a rename was reported as nothing at all.

Finally, get_board reported an unpublished board as "no permission",
which sends the reader hunting for an access problem that is not there.

smoke gains checks for topic tasks and for pads, and no longer assumes a
populated index or a search term that happens to match. 39/39 live-only
and 41/41 index-backed, against both the live instance and a local one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:39:51 +02:00

265 lines
12 KiB
JavaScript

#!/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';
import { closeServices, createServices } from '../dist/services.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();
// Wire the real services so the index-backed tools are exercised when
// DATABASE_URL is set, exactly as the deployed server does.
const services = await createServices(config);
const app = createHttpApp(config, services);
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 collecting boards and lessons, to exercise the whole chain.
// Every board id is collected rather than the first one taken: an unpublished
// board is listed on the course page with its title but 403s when opened, so
// "the first board in the course" is not reliably one that can be read.
let boardId, fileId, lessonId, courseWithBoard;
const boardIds = [];
const topicsWithTasks = [];
for (const id of courseIds) {
const course = await call('get_course', { courseId: id });
if (course.isError) continue;
courseWithBoard ??= id;
const boardsSection = course.text.match(/### Boards[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
for (const m of boardsSection.matchAll(/\(`([0-9a-f]{24})`\)/g)) boardIds.push(m[1]);
lessonId ??= course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
// A topic that reports tasks is the interesting one: those tasks are not task
// elements on the course page and carry no id in the API.
const topics = course.text.match(/### Topics[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
for (const m of topics.matchAll(/\(`([0-9a-f]{24})`\) — (\d+) task/g)) topicsWithTasks.push(m[1]);
}
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
check('found a column board', boardIds.length > 0, `${boardIds.length} board(s)`);
let board, drafts = 0;
for (const id of boardIds) {
const attempt = await call('get_board', { boardId: id });
if (!attempt.isError) {
board = attempt;
boardId = id;
break;
}
if (/draft/i.test(attempt.text)) drafts++;
}
if (boardIds.length > 0) {
check(
'get_board',
Boolean(board) && /Board id:/.test(board.text),
boardId ? `opened ${boardId}${drafts ? `, skipped ${drafts} unpublished` : ''}` : 'no board could be opened',
);
}
if (board) {
// Pads carry real content and the board API returns them empty, so the text
// comes from Etherpad itself. Either it was read, or the tool says plainly
// that it was not — it must never claim the contents cannot be had.
const padLine = board.text.match(/- Collaborative text document `[0-9a-f]{24}`[^\n]*/)?.[0];
check(
'collaborative text documents report contents or say they are empty',
padLine === undefined || /:$|\(empty, or its contents could not be read\)/.test(padLine),
padLine ?? 'no pad on this board',
);
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);
// A task attached to a topic is reachable only if its id was recovered from the
// topic page: the API's topic-task projection has no id field, and such a task
// is on no course page and drops out of both task lists once it is past due.
if (topicsWithTasks.length > 0) {
const lesson = await call('get_lesson', { lessonId: topicsWithTasks[0] });
const topicTaskId = lesson.text.match(/### Tasks in this lesson[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
check('get_lesson lists a topic\'s tasks with ids', Boolean(topicTaskId), topicTaskId ?? lesson.text.slice(0, 90));
if (topicTaskId) {
const viaTopic = await call('get_task', { taskId: topicTaskId });
check('get_task opens a task found only through a topic', !viaTopic.isError && /Task id:/.test(viaTopic.text));
}
} else {
check('get_lesson lists a topic\'s tasks with ids', true, 'no topic on this account reports tasks — nothing to check');
}
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, fresh: true, courseId: courseIds[0] });
check(`search "${searchTerm}" (fresh + scoped, bypassing any index)`, !search.isError, search.text.split('\n')[0]);
const freshAll = await call('search', { query: searchTerm, fresh: true });
check('unscoped fresh search returns without timing out', !freshAll.isError, freshAll.text.split('\n')[0]);
check('search scoped to one course', !(await call('search', { query: 'a b', courseId: courseIds[0], fresh: true })).isError);
console.log('\n== submissions ==');
if (taskId) {
const task = await call('get_task', { taskId });
check('get_task still works with submission lookup', !task.isError);
const subs = await call('list_submissions', { courseId: courseIds[0], scope: 'all' });
check('list_submissions scoped to a course', !subs.isError, subs.text.split('\n')[0]);
const all = await call('list_submissions', { limit: 5 });
check('list_submissions unscoped', !all.isError, all.text.split('\n')[0]);
}
console.log('\n== index tools ==');
// These degrade gracefully without DATABASE_URL, so assert on either outcome
// rather than requiring a database for the smoke run to be meaningful.
const hasIndex = Boolean(process.env.DATABASE_URL);
const status = await call('index_status');
check(
`index_status responds (${hasIndex ? 'with index' : 'no index configured'})`,
hasIndex ? !status.isError : status.isError && /not configured/.test(status.text),
status.text.split('\n')[0],
);
if (hasIndex) {
// Populate before asking what changed: a brand-new index holds no generations
// to diff, and what_changed rightly refuses rather than inventing a baseline.
const refreshed = await call('refresh_index', { courseId: courseIds[0], force: true });
check('refresh_index re-crawls one course', !refreshed.isError, refreshed.text.split('\n')[0]);
}
const changed = await call('what_changed', { since: '2026-01-01' });
check('what_changed responds', hasIndex ? !changed.isError : changed.isError, changed.text.split('\n')[0]);
if (hasIndex) {
// Both the hit and the no-hit answer say when the index was last refreshed;
// a live crawl (fresh=true) says nothing of the sort. That is what separates
// "answered from the index" from "answered by crawling", whatever the term
// happens to match in this account's data.
const indexed = await call('search', { query: searchTerm });
check(
'search uses the index and states freshness',
!indexed.isError && /refreshed/i.test(indexed.text),
indexed.text.split('\n')[0],
);
}
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();
await closeServices(services);
console.log(`\n${results.length - failures}/${results.length} checks passed`);
process.exit(failures === 0 ? 0 : 1);