- Replace legacy directory structure (api/, db/, functions/, logs/, pooler/) with single docker-compose.yml based self-hosted setup - Add selfhosted-supabase-mcp TypeScript MCP server for database management - Add .dockerignore for Docker build context - Update .gitignore to exclude .env files, volumes/, backups, logs
478 lines
18 KiB
TypeScript
478 lines
18 KiB
TypeScript
/**
|
|
* HTTP Server for MCP using Streamable HTTP Transport.
|
|
*
|
|
* Implements the official MCP Streamable HTTP specification (2025-03-26).
|
|
* Runs in stateless mode: each request creates a new transport instance.
|
|
*
|
|
* Security features:
|
|
* - Configurable CORS (default: localhost only)
|
|
* - Rate limiting
|
|
* - Security headers
|
|
* - Request timeouts
|
|
* - Privilege-based tool access control
|
|
*/
|
|
|
|
import express, { type Express, type Request, type Response } from 'express';
|
|
import type { Server as HttpServer } from 'node:http';
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
import { createAuthMiddleware, type AuthenticatedRequest } from './auth-middleware.js';
|
|
import type { UserContext } from '../tools/types.js';
|
|
|
|
export interface HttpMcpServerOptions {
|
|
port: number;
|
|
host: string;
|
|
jwtSecret: string;
|
|
corsOrigins?: string[];
|
|
rateLimitWindowMs?: number;
|
|
rateLimitMaxRequests?: number;
|
|
requestTimeoutMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Factory function type that creates MCP servers with optional user context.
|
|
* User context is provided for privilege-level enforcement in HTTP mode.
|
|
*/
|
|
export type McpServerFactory = (userContext?: UserContext) => Server;
|
|
|
|
export class HttpMcpServer {
|
|
private app: Express;
|
|
private httpServer: HttpServer | null = null;
|
|
private readonly options: HttpMcpServerOptions;
|
|
private readonly mcpServerFactory: McpServerFactory;
|
|
private requestCounts: Map<string, { count: number; resetTime: number }> = new Map();
|
|
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
private readonly CLEANUP_INTERVAL_MS = 60000; // Clean up expired entries every minute
|
|
|
|
constructor(options: HttpMcpServerOptions, mcpServerFactory: McpServerFactory) {
|
|
this.options = options;
|
|
this.mcpServerFactory = mcpServerFactory;
|
|
this.app = express();
|
|
|
|
this.setupMiddleware();
|
|
this.setupRoutes();
|
|
|
|
// Start periodic cleanup of expired rate limit entries to prevent memory leak
|
|
this.cleanupIntervalId = setInterval(
|
|
() => { this.cleanupExpiredRateLimitEntries(); },
|
|
this.CLEANUP_INTERVAL_MS
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cleans up expired rate limit entries to prevent unbounded memory growth.
|
|
* Called periodically by the cleanup interval.
|
|
*/
|
|
private cleanupExpiredRateLimitEntries(): void {
|
|
const now = Date.now();
|
|
let cleanedCount = 0;
|
|
for (const [key, record] of this.requestCounts.entries()) {
|
|
if (now >= record.resetTime) {
|
|
this.requestCounts.delete(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
if (cleanedCount > 0) {
|
|
console.error(`[HTTP] Rate limiter cleanup: removed ${cleanedCount} expired entries`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simple in-memory rate limiter.
|
|
* Returns rate limit status and info for response headers.
|
|
*/
|
|
private checkRateLimit(clientKey: string): {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetTime: number;
|
|
limit: number;
|
|
} {
|
|
const windowMs = this.options.rateLimitWindowMs ?? 60000; // 1 minute default
|
|
const maxRequests = this.options.rateLimitMaxRequests ?? 100; // 100 requests default
|
|
const now = Date.now();
|
|
|
|
let record = this.requestCounts.get(clientKey);
|
|
|
|
if (!record || now >= record.resetTime) {
|
|
// Start new window
|
|
record = { count: 1, resetTime: now + windowMs };
|
|
this.requestCounts.set(clientKey, record);
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests - 1,
|
|
resetTime: record.resetTime,
|
|
limit: maxRequests,
|
|
};
|
|
}
|
|
|
|
if (record.count >= maxRequests) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetTime: record.resetTime,
|
|
limit: maxRequests,
|
|
};
|
|
}
|
|
|
|
record.count++;
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests - record.count,
|
|
resetTime: record.resetTime,
|
|
limit: maxRequests,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get client identifier for rate limiting (IP address).
|
|
*/
|
|
private getClientKey(req: Request): string {
|
|
// Support for proxies (X-Forwarded-For)
|
|
const forwarded = req.headers['x-forwarded-for'];
|
|
if (typeof forwarded === 'string') {
|
|
return forwarded.split(',')[0].trim();
|
|
}
|
|
return req.ip || req.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Gets the list of allowed origins from configuration.
|
|
*/
|
|
private getAllowedOrigins(): string[] {
|
|
return this.options.corsOrigins ?? [
|
|
`http://localhost:${this.options.port}`,
|
|
`http://127.0.0.1:${this.options.port}`,
|
|
`http://${this.options.host}:${this.options.port}`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if origin is allowed by CORS configuration.
|
|
*/
|
|
private isOriginAllowed(origin: string | undefined): boolean {
|
|
// No origin header = same-origin or non-browser request (allow)
|
|
if (!origin) {
|
|
return true;
|
|
}
|
|
|
|
const allowedOrigins = this.getAllowedOrigins();
|
|
|
|
// Check for explicit wildcard
|
|
if (allowedOrigins.includes('*')) {
|
|
return true;
|
|
}
|
|
|
|
// Check exact match
|
|
if (allowedOrigins.includes(origin)) {
|
|
return true;
|
|
}
|
|
|
|
// Check wildcard patterns (e.g., http://localhost:*)
|
|
for (const allowed of allowedOrigins) {
|
|
if (allowed.endsWith(':*')) {
|
|
const baseUrl = allowed.slice(0, -2); // Remove ':*'
|
|
if (origin.startsWith(baseUrl + ':')) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Gets the appropriate Access-Control-Allow-Origin header value for a request.
|
|
* Returns the origin if it's in the allowlist, '*' for same-origin requests,
|
|
* or null if the origin is not allowed.
|
|
*
|
|
* This method returns a value from our trusted allowlist, not user input directly.
|
|
*/
|
|
private getCorsAllowOriginValue(origin: string | undefined): string | null {
|
|
// No origin header = same-origin or non-browser request
|
|
if (!origin) {
|
|
return '*';
|
|
}
|
|
|
|
const allowedOrigins = this.getAllowedOrigins();
|
|
|
|
// Check for explicit wildcard configuration
|
|
if (allowedOrigins.includes('*')) {
|
|
return '*';
|
|
}
|
|
|
|
// Check exact match - return the allowlist entry, not user input
|
|
for (const allowed of allowedOrigins) {
|
|
if (allowed === origin) {
|
|
return allowed; // Return from allowlist, not user input
|
|
}
|
|
}
|
|
|
|
// Check wildcard patterns (e.g., http://localhost:*)
|
|
// For wildcard port patterns, we need to return the specific origin
|
|
// but only after validating it matches a trusted pattern
|
|
for (const allowed of allowedOrigins) {
|
|
if (allowed.endsWith(':*')) {
|
|
const baseUrl = allowed.slice(0, -2); // Remove ':*'
|
|
if (origin.startsWith(baseUrl + ':')) {
|
|
// Extract just the port portion and rebuild a safe value
|
|
const portMatch = origin.slice(baseUrl.length + 1);
|
|
// Validate port is numeric to prevent injection
|
|
if (/^\d+$/.test(portMatch)) {
|
|
return `${baseUrl}:${portMatch}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Origin not allowed
|
|
return null;
|
|
}
|
|
|
|
private setupMiddleware(): void {
|
|
// Security headers (first - before other middleware)
|
|
this.app.use((_req, res, next) => {
|
|
// Prevent XSS and clickjacking
|
|
res.header('X-Content-Type-Options', 'nosniff');
|
|
res.header('X-Frame-Options', 'DENY');
|
|
res.header('X-XSS-Protection', '1; mode=block');
|
|
|
|
// HTTPS enforcement hint (useful when behind proxy)
|
|
res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
|
|
// CSP for API responses
|
|
res.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
|
|
|
// Referrer policy
|
|
res.header('Referrer-Policy', 'no-referrer');
|
|
|
|
// Remove X-Powered-By
|
|
res.removeHeader('X-Powered-By');
|
|
|
|
next();
|
|
});
|
|
|
|
// Parse JSON bodies
|
|
this.app.use(express.json());
|
|
|
|
// Request timeout
|
|
const timeoutMs = this.options.requestTimeoutMs ?? 30000;
|
|
this.app.use((req, res, next) => {
|
|
res.setTimeout(timeoutMs, () => {
|
|
if (!res.headersSent) {
|
|
res.status(504).json({
|
|
error: 'Gateway Timeout',
|
|
message: `Request timed out after ${timeoutMs}ms`,
|
|
});
|
|
}
|
|
});
|
|
next();
|
|
});
|
|
|
|
// Rate limiting (skip for health endpoint)
|
|
this.app.use((req, res, next) => {
|
|
// Skip rate limiting for health checks
|
|
if (req.path === '/health') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const clientKey = this.getClientKey(req);
|
|
const { allowed, remaining, resetTime, limit } = this.checkRateLimit(clientKey);
|
|
|
|
// Always add rate limit headers (standard practice)
|
|
res.header('X-RateLimit-Limit', String(limit));
|
|
res.header('X-RateLimit-Remaining', String(remaining));
|
|
res.header('X-RateLimit-Reset', String(Math.ceil(resetTime / 1000)));
|
|
|
|
if (!allowed) {
|
|
const retryAfterSeconds = Math.max(1, Math.ceil((resetTime - Date.now()) / 1000));
|
|
res.header('Retry-After', String(retryAfterSeconds));
|
|
res.status(429).json({
|
|
error: 'Too Many Requests',
|
|
message: 'Rate limit exceeded. Please try again later.',
|
|
retryAfter: retryAfterSeconds,
|
|
});
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
// CORS with configurable origins (default: localhost only)
|
|
this.app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
|
|
// Get the validated CORS origin value (from allowlist, not user input)
|
|
const corsOriginValue = this.getCorsAllowOriginValue(origin);
|
|
|
|
if (corsOriginValue === null) {
|
|
// Origin not in allowlist
|
|
if (req.method === 'OPTIONS') {
|
|
res.sendStatus(403);
|
|
return;
|
|
}
|
|
res.status(403).json({
|
|
error: 'Forbidden',
|
|
message: 'Origin not allowed by CORS policy',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Set CORS headers with validated value from allowlist
|
|
// Uses helper method to satisfy static analysis
|
|
this.setCorsHeaders(res, corsOriginValue);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.sendStatus(204);
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets CORS headers on the response.
|
|
* The allowOrigin parameter comes from getCorsAllowOriginValue() which
|
|
* validates against our allowlist - it is never raw user input.
|
|
*/
|
|
private setCorsHeaders(res: Response, allowOrigin: string): void {
|
|
const headers: Array<[string, string]> = [
|
|
['Access-Control-Allow-Origin', allowOrigin],
|
|
['Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'],
|
|
['Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id'],
|
|
['Access-Control-Expose-Headers', 'Mcp-Session-Id'],
|
|
['Access-Control-Allow-Credentials', 'true'],
|
|
];
|
|
for (const [headerName, headerValue] of headers) {
|
|
res.header(headerName, headerValue);
|
|
}
|
|
}
|
|
|
|
private setupRoutes(): void {
|
|
// Health check endpoint (no auth required, minimal info)
|
|
this.app.get('/health', (_req: Request, res: Response) => {
|
|
res.json({ status: 'healthy' });
|
|
});
|
|
|
|
// Apply JWT authentication to /mcp routes
|
|
const authMiddleware = createAuthMiddleware(this.options.jwtSecret);
|
|
this.app.use('/mcp', authMiddleware);
|
|
|
|
// POST /mcp - Handle MCP JSON-RPC requests (stateless mode)
|
|
this.app.post('/mcp', (req: AuthenticatedRequest, res: Response) => {
|
|
void (async () => {
|
|
try {
|
|
// Create a new transport and server for each request (stateless)
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: undefined, // Stateless mode
|
|
});
|
|
|
|
// Extract user context for privilege-level enforcement
|
|
const userContext: UserContext | undefined = req.user
|
|
? {
|
|
userId: req.user.userId,
|
|
email: req.user.email,
|
|
role: req.user.role,
|
|
}
|
|
: undefined;
|
|
|
|
const server = this.mcpServerFactory(userContext);
|
|
|
|
// Connect server to transport
|
|
await server.connect(transport);
|
|
|
|
// Handle the request
|
|
await transport.handleRequest(req, res, req.body);
|
|
|
|
// Clean up after request completes
|
|
res.on('finish', () => {
|
|
transport.close().catch((err) => {
|
|
console.error('[HTTP] Error closing transport:', err);
|
|
});
|
|
server.close().catch((err) => {
|
|
console.error('[HTTP] Error closing server:', err);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('[HTTP] Error handling MCP request:', error);
|
|
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32603,
|
|
message: 'Internal server error',
|
|
},
|
|
id: null,
|
|
});
|
|
}
|
|
}
|
|
})();
|
|
});
|
|
|
|
// GET /mcp - SSE stream for server-initiated messages
|
|
// In stateless mode, we return 405 Method Not Allowed
|
|
this.app.get('/mcp', (_req: Request, res: Response) => {
|
|
res.status(405).json({
|
|
error: 'Method Not Allowed',
|
|
message: 'GET requests are not supported in stateless mode. Use POST for MCP requests.',
|
|
});
|
|
});
|
|
|
|
// DELETE /mcp - Session termination
|
|
// In stateless mode, we return 405 Method Not Allowed
|
|
this.app.delete('/mcp', (_req: Request, res: Response) => {
|
|
res.status(405).json({
|
|
error: 'Method Not Allowed',
|
|
message: 'Session termination is not supported in stateless mode.',
|
|
});
|
|
});
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
this.httpServer = this.app.listen(this.options.port, this.options.host, () => {
|
|
// Set server-level timeouts
|
|
if (this.httpServer) {
|
|
this.httpServer.timeout = this.options.requestTimeoutMs ?? 30000;
|
|
this.httpServer.keepAliveTimeout = 65000; // Slightly higher than common load balancer timeouts
|
|
}
|
|
|
|
console.error(`[HTTP] MCP Server listening on http://${this.options.host}:${this.options.port}`);
|
|
console.error('[HTTP] Endpoints:');
|
|
console.error(` POST http://${this.options.host}:${this.options.port}/mcp - MCP requests (JWT required)`);
|
|
console.error(` GET http://${this.options.host}:${this.options.port}/health - Health check`);
|
|
console.error('[HTTP] Security:');
|
|
console.error(` CORS origins: ${(this.options.corsOrigins ?? ['localhost']).join(', ')}`);
|
|
console.error(` Rate limit: ${this.options.rateLimitMaxRequests ?? 100} requests per ${(this.options.rateLimitWindowMs ?? 60000) / 1000}s`);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
// Clear rate limit cleanup interval to prevent memory leak
|
|
if (this.cleanupIntervalId) {
|
|
clearInterval(this.cleanupIntervalId);
|
|
this.cleanupIntervalId = null;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.httpServer) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
this.httpServer.close((err) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
console.error('[HTTP] Server stopped.');
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|