Files
Schulcloud-MCP/scripts/smoke.mjs
MechaCat02 ac08e17b48 Expose submissions: list_submissions, and get_task shows your own
Adds what the API actually permits, which is less than the request asked
for and worth being precise about.

GET /api/v3/submissions/status/task/{taskId} is the only submission
route — no list, no fetch-by-id — so a task id is the only way in. The
probe in the report missed it by trying /api/v3/submissions (404). Its
payload is {id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName} and nothing more: no submitted text, no grade
comment, no graded-at. Those lived on /api/v1, which this instance does
not serve at all (404 across the board, confirmed — not the proxy). So
"what feedback did I get" is answerable only when the feedback is a file.

Submitted files are reachable, which covers the main workflow:
get_task now shows the submission id, graded state, grade, group, and
the handed-in files with ids ready for download_file. list_submissions
surveys tasks for "what have I handed in" and "what is still ungraded".
Both state the text/feedback gap rather than implying none was given.

Two things found while building it:

files-storage ignores the parentType path segment when listing —
.../gradings/{id} returns the same records, saying parentType
"submissions". Filtering on each record's own parentType, or a student's
own upload gets reported back as teacher feedback.

get_task could not find this task at all: the task lists only cover the
dashboard, and group-project tasks are absent from both, so it claimed
the id was wrong for a task the account can plainly see. It now falls
back to scanning course pages.

Also bounds live search by measured cost: resolving attachments needs a
request per board element, which is 2s for one course but 325s for all
of them — beyond any client timeout. An unscoped fresh search now reads
text only and says so.

38/38 smoke checks; verified end to end through Claude Code against a
real graded group submission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:19:11 +02:00

207 lines
9.0 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 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, 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],
);
const changed = await call('what_changed', { since: '2026-01-01' });
check('what_changed responds', hasIndex ? !changed.isError : changed.isError);
if (hasIndex) {
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 indexed = await call('search', { query: searchTerm });
check('search uses the index and states freshness', !indexed.isError && /Index /.test(indexed.text));
}
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);