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:
144
src/http/server.ts
Normal file
144
src/http/server.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import crypto from 'node:crypto';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Config } from '../config.ts';
|
||||
import { createServer } from '../server.ts';
|
||||
import { bearerAuth } from './auth.ts';
|
||||
|
||||
/**
|
||||
* Streamable-HTTP front end, for use as a remote MCP connector.
|
||||
*
|
||||
* Sessions are stateful: a client POSTs `initialize`, gets an
|
||||
* `Mcp-Session-Id` back, and reuses it for subsequent POSTs, an optional GET
|
||||
* (the SSE channel for server-initiated messages), and a DELETE to close.
|
||||
* Each session owns one McpServer instance, which keeps per-session caches
|
||||
* (the `/me` lookup) from leaking between callers.
|
||||
*/
|
||||
|
||||
const MCP_PATH = '/mcp';
|
||||
/** Sessions are dropped after this long without traffic, in case DELETE never arrives. */
|
||||
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
interface Session {
|
||||
transport: StreamableHTTPServerTransport;
|
||||
close: () => Promise<void>;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export function createHttpApp(config: Config): express.Express {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
// Caddy sits in front and terminates TLS; trust its forwarding headers so
|
||||
// logged client IPs are real rather than the proxy's.
|
||||
app.set('trust proxy', true);
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
const sweep = setInterval(() => {
|
||||
const cutoff = Date.now() - SESSION_IDLE_MS;
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.lastSeen < cutoff) {
|
||||
sessions.delete(id);
|
||||
void session.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
sweep.unref();
|
||||
|
||||
// Liveness probe for Docker/Caddy. Deliberately before auth and free of any
|
||||
// detail about the instance or the account.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.json({ status: 'ok', sessions: sessions.size });
|
||||
});
|
||||
|
||||
if (config.authToken) {
|
||||
app.use(MCP_PATH, bearerAuth(config.authToken));
|
||||
} else {
|
||||
console.warn(
|
||||
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
||||
'Only acceptable when bound to localhost or an otherwise private network.',
|
||||
);
|
||||
}
|
||||
|
||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||
|
||||
app.post(MCP_PATH, async (req: Request, res: Response) => {
|
||||
const sessionId = req.get('mcp-session-id');
|
||||
|
||||
try {
|
||||
if (sessionId) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json(rpcError(-32001, 'Unknown or expired session. Re-initialize.'));
|
||||
return;
|
||||
}
|
||||
session.lastSeen = Date.now();
|
||||
await session.transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInitializeRequest(req.body)) {
|
||||
res.status(400).json(rpcError(-32000, 'Missing Mcp-Session-Id header; send an initialize request first.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { server } = createServer(config);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
sessions.set(id, {
|
||||
transport,
|
||||
close: async () => {
|
||||
await transport.close().catch(() => {});
|
||||
await server.close().catch(() => {});
|
||||
},
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
console.log(`[schulcloud-mcp] session ${id} initialized`);
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
sessions.delete(transport.sessionId);
|
||||
console.log(`[schulcloud-mcp] session ${transport.sessionId} closed`);
|
||||
}
|
||||
};
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('[schulcloud-mcp] POST failed:', error);
|
||||
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
|
||||
}
|
||||
});
|
||||
|
||||
// GET opens the server→client SSE stream; DELETE ends the session.
|
||||
const bySession = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.get('mcp-session-id');
|
||||
const session = sessionId ? sessions.get(sessionId) : undefined;
|
||||
if (!session) {
|
||||
res.status(404).json(rpcError(-32001, 'Unknown or expired session.'));
|
||||
return;
|
||||
}
|
||||
session.lastSeen = Date.now();
|
||||
try {
|
||||
await session.transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
console.error('[schulcloud-mcp] session request failed:', error);
|
||||
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
|
||||
}
|
||||
};
|
||||
|
||||
app.get(MCP_PATH, bySession);
|
||||
app.delete(MCP_PATH, bySession);
|
||||
|
||||
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function rpcError(code: number, message: string) {
|
||||
return { jsonrpc: '2.0' as const, error: { code, message }, id: null };
|
||||
}
|
||||
Reference in New Issue
Block a user