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 '../mcp/server.ts'; import type { Services } from '../services.ts'; import { createApiRouter } from './api.ts'; import { createAppRouter } from './app-page.ts'; import { bearerAuth, pathSecret } from './auth.ts'; import { tokenPage, tokenScript } from './token-page.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'; /** The CLI's surface: file bytes, sync manifest, on-demand re-crawl. */ const API_PATH = '/api'; /** 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; lastSeen: number; } export function createHttpApp(config: Config, services?: Services): 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(); 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, index: services?.store ? 'on' : 'off' }); }); // MCP_AUTH_TOKEN opens both surfaces: the MCP endpoint and the CLI's API. The // connector token opens /mcp alone. claude.ai stores it as a request header, // and a credential held by a third party should reach the read-only tools, // not /api, which can replace the Schulcloud token and stream the file mirror. // The web app, when a password is configured. Mounted before the token gate // so its login screen is reachable without one — it is the thing that issues // the session everything else then accepts. const appSurface = services ? createAppRouter(config) : undefined; if (appSurface) app.use('/app', appSurface.router); const loggedIn = appSurface ? (req: Request) => appSurface.auth.verify(req.get('cookie')) : undefined; if (config.authToken) { app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken)); app.use(API_PATH, bearerAuth(config.authToken, loggedIn)); } 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.', ); } if (services) { app.use(API_PATH, createApiRouter(services)); // The page to paste a fresh Schulcloud token into. It holds no secret: what // it sends goes to /api/token, behind the bearer check above. app.get('/token', tokenPage); app.get('/token.js', tokenScript); } const handlePost = async (req: Request, res: Response): Promise => { 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, services); 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')); } }; app.post(MCP_PATH, express.json({ limit: '4mb' }), handlePost); // GET opens the server→client SSE stream; DELETE ends the session. const bySession = async (req: Request, res: Response): Promise => { 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); // The same endpoint without a bearer token, for clients that cannot send one: // claude.ai's connector dialog takes only a URL. The path is the credential // here, so nothing in this server logs request paths — keep it that way — and // the Caddy snippet redacts it from the access log. A stopgap until the // endpoint speaks OAuth, which is what connectors are meant to use. if (config.mcpPathSecret) { const secretMcpPath = '/:secret/mcp'; const gate = pathSecret(config.mcpPathSecret); app.post(secretMcpPath, gate, express.json({ limit: '4mb' }), handlePost); app.get(secretMcpPath, gate, bySession); app.delete(secretMcpPath, gate, 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 }; }