mcp-searxng
Version:
MCP server for SearXNG integration
556 lines (555 loc) • 22.5 kB
JavaScript
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { randomUUID } from "crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { logMessage } from "./logging.js";
import { packageVersion } from "./version.js";
import { sanitizeDiagnosticText, sanitizeDiagnosticValue, sanitizeErrorForTransport, } from "./diagnostic-sanitizer.js";
import { writeDiagnostic } from "./diagnostic-output.js";
import { parseStrictInteger } from "./env-int.js";
import { getHttpSecurityConfig, isOriginAllowed, isRequestAuthorized, validateHttpSecurityConfig, } from "./http-security.js";
export const DEFAULT_STATELESS_MAX_IN_FLIGHT = 16;
export const DEFAULT_STATELESS_MAX_IN_FLIGHT_PER_IP = 8;
export const DEFAULT_STATELESS_REQUEST_TIMEOUT_MS = 900000;
export const MAX_STATELESS_MAX_IN_FLIGHT = 256;
const MIN_STATELESS_REQUEST_TIMEOUT_MS = 1000;
const MAX_STATELESS_REQUEST_TIMEOUT_MS = 2147483647;
const STATELESS_CLEANUP_DEADLINE_MS = 5000;
const STATELESS_WARNING_INTERVAL_MS = 60000;
function warnDiagnostic(message, data) {
if (data === undefined) {
writeDiagnostic("warn", sanitizeDiagnosticText(message));
return;
}
writeDiagnostic("warn", sanitizeDiagnosticText(message), sanitizeDiagnosticValue(data));
}
/**
* Resolves the bind host from the MCP_HTTP_HOST environment variable.
* Falls back to "127.0.0.1" (localhost only) when the variable is absent or whitespace-only.
* Set MCP_HTTP_HOST=0.0.0.0 to expose on all interfaces (e.g. Docker, remote access).
*/
export function resolveBindHost(envValue) {
const trimmed = envValue?.trim();
if (!trimmed) {
return "127.0.0.1";
}
return trimmed;
}
/**
* Parses a positive-integer rate-limit setting from the environment.
* Absent/blank → fallback silently. Present-but-invalid or non-positive →
* fallback plus a one-line console.warn so an operator typo cannot silently
* disable rate limiting (a fail-open control). Uses console.warn, not the MCP
* logMessage path, because makeRateLimiters runs without an McpServer in scope.
*/
export function parseRateLimitEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") {
return fallback;
}
const parsed = parseStrictInteger(raw);
if (parsed === undefined || parsed <= 0) {
warnDiagnostic(`⚠️ Ignoring invalid ${name}. Expected a positive integer. Using default ${fallback}.`);
return fallback;
}
return parsed;
}
function parseBoundedStatelessEnv(name, fallback, minimum, maximum) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") {
return fallback;
}
const parsed = parseStrictInteger(raw);
if (parsed === undefined || parsed < minimum || parsed > maximum) {
warnDiagnostic(`⚠️ Ignoring invalid ${name}. Expected an integer from ${minimum} through ${maximum}. Using default ${fallback}.`);
return fallback;
}
return parsed;
}
function resolveStatelessEnabled() {
const raw = process.env.MCP_HTTP_STATELESS;
const value = raw?.trim();
if (!value || value === "false")
return false;
if (value === "true")
return true;
warnDiagnostic("⚠️ Ignoring invalid MCP_HTTP_STATELESS. Expected true or false. Using false.");
return false;
}
export function resolveStatelessHttpConfig() {
const maxInFlight = parseBoundedStatelessEnv("MCP_HTTP_STATELESS_MAX_IN_FLIGHT", DEFAULT_STATELESS_MAX_IN_FLIGHT, 1, MAX_STATELESS_MAX_IN_FLIGHT);
const requestedPerIp = parseBoundedStatelessEnv("MCP_HTTP_STATELESS_MAX_IN_FLIGHT_PER_IP", DEFAULT_STATELESS_MAX_IN_FLIGHT_PER_IP, 1, MAX_STATELESS_MAX_IN_FLIGHT);
const maxInFlightPerIp = Math.min(requestedPerIp, maxInFlight);
if (requestedPerIp > maxInFlight) {
warnDiagnostic(`⚠️ Ignoring invalid MCP_HTTP_STATELESS_MAX_IN_FLIGHT_PER_IP. Expected a value no greater than MCP_HTTP_STATELESS_MAX_IN_FLIGHT. Using ${maxInFlight}.`);
}
const requestTimeoutMs = parseBoundedStatelessEnv("MCP_HTTP_STATELESS_REQUEST_TIMEOUT_MS", DEFAULT_STATELESS_REQUEST_TIMEOUT_MS, MIN_STATELESS_REQUEST_TIMEOUT_MS, MAX_STATELESS_REQUEST_TIMEOUT_MS);
return {
enabled: resolveStatelessEnabled(),
maxInFlight,
maxInFlightPerIp,
requestTimeoutMs,
};
}
function makeRateLimiters() {
const windowMs = parseRateLimitEnv("MCP_RATE_WINDOW_MS", 60000);
const initLimiter = rateLimit({
windowMs,
max: parseRateLimitEnv("MCP_RATE_INIT_MAX", 20),
standardHeaders: true,
legacyHeaders: false,
message: {
jsonrpc: "2.0",
error: { code: -32029, message: "Too many requests" },
id: null,
},
});
const sessionLimiter = rateLimit({
windowMs,
max: parseRateLimitEnv("MCP_RATE_SESSION_MAX", 300),
standardHeaders: true,
legacyHeaders: false,
message: {
jsonrpc: "2.0",
error: { code: -32029, message: "Too many requests" },
id: null,
},
});
const healthLimiter = rateLimit({
windowMs: 60000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
});
return { initLimiter, sessionLimiter, healthLimiter };
}
export async function createHttpServer(createMcpServer, port) {
const app = express();
const security = getHttpSecurityConfig(port);
const stateless = resolveStatelessHttpConfig();
validateHttpSecurityConfig(security);
if (security.trustProxy !== false) {
app.set('trust proxy', security.trustProxy);
}
app.use(express.json());
// Add CORS support for web clients
app.use(cors({
origin: (origin, callback) => {
if (isOriginAllowed(origin || undefined, security)) {
callback(null, true);
return;
}
callback(null, false);
},
exposedHeaders: ["Mcp-Session-Id"],
allowedHeaders: ["Content-Type", "mcp-session-id", "authorization", "mcp-protocol-version"],
}));
function rejectUnauthorized(res) {
res.status(401).json({
jsonrpc: "2.0",
error: {
code: -32001,
message: "Unauthorized: missing or invalid HTTP auth token",
},
id: null,
});
}
function rejectInvalidStatelessHeaders(req, res) {
if (!security.enableDnsRebindingProtection) {
return false;
}
const host = req.headers.host;
if (security.allowedHosts.length > 0 && (!host || !security.allowedHosts.includes(host))) {
res.status(403).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Invalid Host header: ${host}` },
id: null,
});
return true;
}
const origin = req.headers.origin;
if (origin && !isOriginAllowed(origin, security)) {
res.status(403).json({
jsonrpc: "2.0",
error: { code: -32000, message: `Invalid Origin header: ${origin}` },
id: null,
});
return true;
}
return false;
}
function rejectInvalidPost(req, res, sessionId) {
warnDiagnostic(`⚠️ POST request rejected - invalid request:`, {
clientIP: req.ip || req.socket.remoteAddress,
sessionId: sessionId || 'undefined',
hasInitializeRequest: isInitializeRequest(req.body),
userAgent: req.headers['user-agent'],
contentType: req.headers['content-type'],
accept: req.headers['accept']
});
const hasSessionId = Boolean(sessionId);
res.status(hasSessionId ? 404 : 400).json({
jsonrpc: '2.0',
error: {
code: hasSessionId ? -32001 : -32000,
message: hasSessionId ? 'Session not found' : 'Bad Request: No valid session ID provided',
},
id: null,
});
}
async function handleTransportRequest(transport, req, res) {
try {
await transport.handleRequest(req, res, req.body);
}
catch (error) {
if (error instanceof Error && error.message.includes('accept')) {
warnDiagnostic(`⚠️ Connection rejected due to missing headers:`, {
clientIP: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'],
contentType: req.headers['content-type'],
accept: req.headers['accept'],
error: error.message
});
}
throw sanitizeErrorForTransport(error);
}
}
const { initLimiter, sessionLimiter, healthLimiter } = makeRateLimiters();
// Map to store sessions by session ID
const sessions = new Map();
let statelessInFlight = 0;
const statelessInFlightByIp = new Map();
let lastCapacityWarningAt = 0;
let suppressedCapacityWarnings = 0;
function resolvedClientIp(req) {
return req.ip || req.socket.remoteAddress || "unknown";
}
function warnStatelessCapacity(reason) {
const now = Date.now();
if (now - lastCapacityWarningAt < STATELESS_WARNING_INTERVAL_MS) {
suppressedCapacityWarnings += 1;
return;
}
warnDiagnostic("⚠️ Stateless HTTP capacity exhausted.", {
reason,
inFlight: statelessInFlight,
maxInFlight: stateless.maxInFlight,
suppressedSinceLastWarning: suppressedCapacityWarnings,
});
lastCapacityWarningAt = now;
suppressedCapacityWarnings = 0;
}
function admitStatelessRequest(req, res) {
const clientIp = resolvedClientIp(req);
const clientInFlight = statelessInFlightByIp.get(clientIp) ?? 0;
const reason = clientInFlight >= stateless.maxInFlightPerIp
? "per-ip"
: statelessInFlight >= stateless.maxInFlight
? "global"
: undefined;
if (reason) {
warnStatelessCapacity(reason);
res.set("Retry-After", "1").status(503).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Server busy" },
id: null,
});
return undefined;
}
statelessInFlight += 1;
statelessInFlightByIp.set(clientIp, clientInFlight + 1);
let released = false;
return () => {
if (released)
return;
released = true;
statelessInFlight -= 1;
const remaining = (statelessInFlightByIp.get(clientIp) ?? 1) - 1;
if (remaining <= 0)
statelessInFlightByIp.delete(clientIp);
else
statelessInFlightByIp.set(clientIp, remaining);
};
}
const postRateLimiter = (req, res, next) => {
if (stateless.enabled) {
const selectedLimiter = isInitializeRequest(req.body)
? initLimiter
: sessionLimiter;
selectedLimiter(req, res, next);
return;
}
const sessionId = req.headers['mcp-session-id'];
// Node comma-joins duplicate custom headers. Only one exact live session ID
// selects the generous bucket; every other value stays initialization-limited.
const selectedLimiter = typeof sessionId === 'string' && sessions.has(sessionId)
? sessionLimiter
: initLimiter;
selectedLimiter(req, res, next);
};
// Handle POST requests for client-to-server communication
app.post('/mcp', postRateLimiter, async (req, res) => {
if (!isRequestAuthorized(req.headers.authorization, security)) {
rejectUnauthorized(res);
return;
}
if (stateless.enabled) {
if (rejectInvalidStatelessHeaders(req, res)) {
return;
}
const releaseCapacity = admitStatelessRequest(req, res);
if (!releaseCapacity)
return;
let mcpServer;
let transport;
let requestTimer;
let cleanupPromise;
let requestTimedOut = false;
const requestDeadline = Date.now() + stateless.requestTimeoutMs;
const cleanup = () => {
if (cleanupPromise)
return cleanupPromise;
cleanupPromise = (async () => {
if (requestTimer)
clearTimeout(requestTimer);
const closeResources = async () => {
const errors = [];
try {
await transport?.close();
}
catch (error) {
errors.push(error);
}
try {
await mcpServer?.close();
}
catch (error) {
errors.push(error);
}
if (errors.length)
throw new AggregateError(errors, "Stateless resource cleanup failed");
};
let cleanupDeadline;
try {
await Promise.race([
closeResources(),
new Promise((_resolve, reject) => {
cleanupDeadline = setTimeout(() => reject(new Error("Stateless resource cleanup timed out")), STATELESS_CLEANUP_DEADLINE_MS);
cleanupDeadline.unref();
}),
]);
}
catch (error) {
warnDiagnostic("⚠️ Stateless HTTP cleanup did not complete normally.", error);
}
finally {
if (cleanupDeadline)
clearTimeout(cleanupDeadline);
releaseCapacity();
}
})();
return cleanupPromise;
};
const handleStatelessTimeout = () => {
if (requestTimedOut)
return;
requestTimedOut = true;
warnDiagnostic("⚠️ Stateless HTTP request reached its lifetime limit.", {
timeoutMs: stateless.requestTimeoutMs,
});
if (!res.headersSent && !res.destroyed) {
res.status(504).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Stateless request timed out" },
id: null,
});
}
else if (!res.destroyed) {
res.destroy();
}
void cleanup();
};
const cleanupAfterResponse = () => {
void cleanup();
};
res.once('finish', cleanupAfterResponse);
res.once('close', cleanupAfterResponse);
requestTimer = setTimeout(handleStatelessTimeout, stateless.requestTimeoutMs);
requestTimer.unref();
try {
mcpServer = createMcpServer();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableDnsRebindingProtection: security.enableDnsRebindingProtection,
allowedHosts: security.allowedHosts,
allowedOrigins: security.allowedOrigins,
});
if (Date.now() >= requestDeadline) {
handleStatelessTimeout();
await cleanup();
return;
}
await mcpServer.connect(transport);
await handleTransportRequest(transport, req, res);
}
catch (error) {
await cleanup();
if (requestTimedOut)
return;
throw error;
}
return;
}
const sessionId = req.headers['mcp-session-id'];
let transport;
let mcpServer;
if (sessionId && sessions.has(sessionId)) {
// Reuse existing session
const session = sessions.get(sessionId);
transport = session.transport;
mcpServer = session.mcpServer;
logMessage(mcpServer, "debug", `Reusing session: ${sessionId}`);
}
else if (isInitializeRequest(req.body)) {
// New initialization request — create fresh McpServer and transport
mcpServer = createMcpServer();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
sessions.set(sessionId, { transport, mcpServer });
logMessage(mcpServer, "debug", `Session initialized: ${sessionId}`);
},
enableDnsRebindingProtection: security.enableDnsRebindingProtection,
allowedHosts: security.allowedHosts,
allowedOrigins: security.allowedOrigins,
});
// Clean up session when transport closes
transport.onclose = () => {
if (transport.sessionId) {
sessions.delete(transport.sessionId);
}
};
// Connect this session's McpServer to its transport
await mcpServer.connect(transport);
}
else {
rejectInvalidPost(req, res, sessionId);
return;
}
await handleTransportRequest(transport, req, res);
});
// Handle GET requests for server-to-client notifications via SSE
app.get('/mcp', sessionLimiter, async (req, res) => {
if (!isRequestAuthorized(req.headers.authorization, security)) {
rejectUnauthorized(res);
return;
}
if (stateless.enabled) {
if (rejectInvalidStatelessHeaders(req, res)) {
return;
}
res.set('Allow', 'POST').status(405).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed' },
id: null,
});
return;
}
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) {
warnDiagnostic(`⚠️ GET request rejected - missing or invalid session ID:`, {
clientIP: req.ip || req.socket.remoteAddress,
sessionId: sessionId || 'undefined',
userAgent: req.headers['user-agent']
});
res.status(400).send('Invalid or missing session ID');
return;
}
const session = sessions.get(sessionId);
try {
await session.transport.handleRequest(req, res);
}
catch (error) {
warnDiagnostic(`⚠️ GET request failed:`, {
clientIP: req.ip || req.socket.remoteAddress,
sessionId,
error: error instanceof Error ? error.message : String(error)
});
throw sanitizeErrorForTransport(error);
}
});
// Handle DELETE requests for session termination
app.delete('/mcp', sessionLimiter, async (req, res) => {
if (!isRequestAuthorized(req.headers.authorization, security)) {
rejectUnauthorized(res);
return;
}
if (stateless.enabled) {
if (rejectInvalidStatelessHeaders(req, res)) {
return;
}
res.set('Allow', 'POST').status(405).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed' },
id: null,
});
return;
}
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) {
warnDiagnostic(`⚠️ DELETE request rejected - missing or invalid session ID:`, {
clientIP: req.ip || req.socket.remoteAddress,
sessionId: sessionId || 'undefined',
userAgent: req.headers['user-agent']
});
res.status(400).send('Invalid or missing session ID');
return;
}
const session = sessions.get(sessionId);
try {
await session.transport.handleRequest(req, res);
}
catch (error) {
warnDiagnostic(`⚠️ DELETE request failed:`, {
clientIP: req.ip || req.socket.remoteAddress,
sessionId,
error: error instanceof Error ? error.message : String(error)
});
throw sanitizeErrorForTransport(error);
}
finally {
sessions.delete(sessionId);
}
});
// Health check endpoint
app.get('/health', healthLimiter, (_req, res) => {
res.json({
status: 'healthy',
server: 'ihor-sokoliuk/mcp-searxng',
version: packageVersion,
transport: 'http'
});
});
// Express catches rejected async route handlers here. Keep this after every
// route so session creation/connect failures cannot fall through to
// Express's development error page, which includes the raw stack.
app.use((error, _req, res, next) => {
const safeError = sanitizeErrorForTransport(error);
warnDiagnostic("HTTP request failed:", safeError);
if (res.headersSent) {
next(safeError);
return;
}
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: null,
});
});
return app;
}