|
|
@@ -0,0 +1,206 @@
|
|
|
+import http from 'node:http';
|
|
|
+import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
|
+import { WikiJSMcpServer } from '../mcp/index.js';
|
|
|
+import type { WikiJSClient } from '../wikijs/index.js';
|
|
|
+import type { WikiConfig } from '../config/index.js';
|
|
|
+
|
|
|
+export interface HttpServerOptions {
|
|
|
+ version: string;
|
|
|
+ description: string;
|
|
|
+ enableEdit: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface Session {
|
|
|
+ transport: StreamableHTTPServerTransport;
|
|
|
+ server: WikiJSMcpServer;
|
|
|
+}
|
|
|
+
|
|
|
+/** Mirror of the SDK default (4 MiB) for pre-read bodies. */
|
|
|
+const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Constant-time bearer token comparison. Both sides are SHA-256 hashed first
|
|
|
+ * so timingSafeEqual always receives buffers of equal length.
|
|
|
+ */
|
|
|
+export function isBearerTokenValid(provided: string, expected: string): boolean {
|
|
|
+ const ha = createHash('sha256').update(provided).digest();
|
|
|
+ const hb = createHash('sha256').update(expected).digest();
|
|
|
+ return timingSafeEqual(ha, hb);
|
|
|
+}
|
|
|
+
|
|
|
+interface ParsedBody {
|
|
|
+ ok: true;
|
|
|
+ value: unknown;
|
|
|
+}
|
|
|
+
|
|
|
+interface BodyError {
|
|
|
+ ok: false;
|
|
|
+ status: 400 | 413;
|
|
|
+ message: string;
|
|
|
+}
|
|
|
+
|
|
|
+async function readBody(req: http.IncomingMessage): Promise<ParsedBody | BodyError> {
|
|
|
+ const chunks: Buffer[] = [];
|
|
|
+ let size = 0;
|
|
|
+ for await (const chunk of req) {
|
|
|
+ size += (chunk as Buffer).length;
|
|
|
+ if (size > MAX_BODY_BYTES) {
|
|
|
+ return { ok: false, status: 413, message: `request body exceeds limit of ${MAX_BODY_BYTES} bytes` };
|
|
|
+ }
|
|
|
+ chunks.push(chunk as Buffer);
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString('utf8')) };
|
|
|
+ } catch {
|
|
|
+ return { ok: false, status: 400, message: 'parse error: invalid JSON' };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isInitializeMessage(value: unknown): boolean {
|
|
|
+ return (
|
|
|
+ typeof value === 'object' &&
|
|
|
+ value !== null &&
|
|
|
+ 'method' in value &&
|
|
|
+ (value as { method?: unknown }).method === 'initialize'
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function sendJson(res: http.ServerResponse, status: number, payload: Record<string, unknown>, headers: Record<string, string> = {}): void {
|
|
|
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
|
+ res.end(JSON.stringify(payload));
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Start the MCP server in Streamable HTTP mode (stateful sessions).
|
|
|
+ *
|
|
|
+ * Routes:
|
|
|
+ * - POST /mcp initialize / messages (Accept: application/json, text/event-stream)
|
|
|
+ * - GET /mcp server-to-client SSE stream for an established session
|
|
|
+ * - DELETE /mcp terminate a session
|
|
|
+ * - GET /health liveness probe (no auth)
|
|
|
+ *
|
|
|
+ * When WIKIJS_MCP_BEARER_TOKEN is configured, every /mcp request must carry
|
|
|
+ * `Authorization: Bearer <token>`. Config validation already enforces that a
|
|
|
+ * non-loopback bind requires a token (see src/config/index.ts).
|
|
|
+ */
|
|
|
+export async function startHttpServer(
|
|
|
+ cfg: WikiConfig,
|
|
|
+ options: HttpServerOptions,
|
|
|
+ wikiClient: WikiJSClient
|
|
|
+): Promise<http.Server> {
|
|
|
+ const sessions = new Map<string, Session>();
|
|
|
+ const expectedToken = cfg.MCP_BEARER_TOKEN;
|
|
|
+
|
|
|
+ const server = http.createServer((req, res) => {
|
|
|
+ void handleRequest(req, res).catch((error) => {
|
|
|
+ console.error('Error handling request:', error);
|
|
|
+ if (!res.headersSent) {
|
|
|
+ sendJson(res, 500, { error: 'internal server error' });
|
|
|
+ } else {
|
|
|
+ res.end();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
|
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
|
+ const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
|
+
|
|
|
+ if (req.method === 'GET' && pathname === '/health') {
|
|
|
+ sendJson(res, 200, { status: 'ok', transport: 'http' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (pathname !== '/mcp') {
|
|
|
+ sendJson(res, 404, { error: 'not found' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (expectedToken) {
|
|
|
+ const authHeader = req.headers.authorization;
|
|
|
+ const provided = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined;
|
|
|
+ if (!provided || !isBearerTokenValid(provided, expectedToken)) {
|
|
|
+ sendJson(res, 401, { error: 'unauthorized: Bearer token required' }, { 'www-authenticate': 'Bearer' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const sessionIdHeader = req.headers['mcp-session-id'];
|
|
|
+ const sessionId = typeof sessionIdHeader === 'string' ? sessionIdHeader : undefined;
|
|
|
+
|
|
|
+ if (req.method === 'POST') {
|
|
|
+ if (sessionId) {
|
|
|
+ const session = sessions.get(sessionId);
|
|
|
+ if (!session) {
|
|
|
+ sendJson(res, 404, { error: 'session not found' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const body = await readBody(req);
|
|
|
+ if (!body.ok) {
|
|
|
+ sendJson(res, body.status, { error: body.message });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await session.transport.handleRequest(req, res, body.value);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // No session id yet: only an initialize request may create a session.
|
|
|
+ const body = await readBody(req);
|
|
|
+ if (!body.ok) {
|
|
|
+ sendJson(res, body.status, { error: body.message });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!isInitializeMessage(body.value)) {
|
|
|
+ sendJson(res, 400, { error: 'bad request: missing session id (initialize first)' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const transport = new StreamableHTTPServerTransport({
|
|
|
+ sessionIdGenerator: () => randomUUID(),
|
|
|
+ onsessioninitialized: (id) => {
|
|
|
+ sessions.set(id, { transport, server: mcpServer });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ const mcpServer = new WikiJSMcpServer(wikiClient, {
|
|
|
+ version: options.version,
|
|
|
+ description: options.description,
|
|
|
+ enableEdit: options.enableEdit
|
|
|
+ });
|
|
|
+ transport.onclose = () => {
|
|
|
+ if (transport.sessionId) {
|
|
|
+ sessions.delete(transport.sessionId);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ await mcpServer.connect(transport);
|
|
|
+ await transport.handleRequest(req, res, body.value);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (req.method === 'GET' || req.method === 'DELETE') {
|
|
|
+ if (!sessionId || !sessions.has(sessionId)) {
|
|
|
+ sendJson(res, 404, { error: 'session not found' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const session = sessions.get(sessionId)!;
|
|
|
+ await session.transport.handleRequest(req, res);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ sendJson(res, 405, { error: 'method not allowed' }, { allow: 'POST, GET, DELETE' });
|
|
|
+ }
|
|
|
+
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
+ server.listen(cfg.MCP_PORT, cfg.MCP_HOST, resolve);
|
|
|
+ });
|
|
|
+
|
|
|
+ const host = cfg.MCP_HOST ?? 'unknown';
|
|
|
+ const authMode = expectedToken ? 'bearer token' : 'none (loopback bind)';
|
|
|
+ console.error(`WikiJS MCP server listening on http://${host}:${cfg.MCP_PORT}/mcp`);
|
|
|
+ console.error(`transport: streamable http (stateful sessions), auth: ${authMode}`);
|
|
|
+ if (!expectedToken) {
|
|
|
+ console.error('warning: no WIKIJS_MCP_BEARER_TOKEN configured — only acceptable for loopback binds');
|
|
|
+ }
|
|
|
+
|
|
|
+ return server;
|
|
|
+}
|