#!/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 }); check(`search "${searchTerm}" (fresh, bypassing any index)`, !search.isError, search.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== 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);