Skip to main content
Glama
server.ts18.5 kB
/** * HTTP server entrypoint for the optimized Raindrop MCP service. * * Exposes the MCP server over HTTP (default port 3002) with session management and CORS. * Uses the optimized MCP service with AI-friendly tool descriptions and robust error handling. * * Compare with the original HTTP server on port 3001 for differences in tool coverage and design. */ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { config } from 'dotenv'; import { randomUUID } from "node:crypto"; import http from 'node:http'; import { parse as parseUrl } from 'node:url'; import { AuthorizationCode } from 'simple-oauth2'; import { RaindropMCPService } from './services/raindropmcp.service.js'; import { createLogger } from './utils/logger.js'; config({ quiet: true }); // Load .env file const PORT = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT) : 3002; const logger = createLogger('http'); /** * Tracks active MCP sessions for monitoring and cleanup. * @type {Map<string, any>} */ const activeSessions = new Map(); /** * Stores session metadata for each active session. * @type {Map<string, any>} */ const sessionMetadata = new Map(); /** * Checks if a request body is an MCP initialization request. * @param body - The request body to check. * @returns True if the request is an MCP initialize call. */ function isInitializeRequest(body: any): boolean { return body && body.method === 'initialize' && body.jsonrpc === '2.0'; } // Raindrop.io OAuth endpoints /** * Lightweight app placeholder for compatibility with tests that import `app`. * When using native http server there is no Express `app`, so we export a * minimal object with the same shape used by tests (serverInstance and listen). */ const app: { listen?: (port: number, cb?: () => void) => any } = {}; const RAINDROP_CLIENT_SECRET = process.env.RAINDROP_CLIENT_SECRET; const RAINDROP_REDIRECT_URI = process.env.RAINDROP_REDIRECT_URI || `http://localhost:${PORT}/auth/raindrop/callback`; const oauthClient = new AuthorizationCode({ client: { id: process.env.RAINDROP_CLIENT_ID, secret: RAINDROP_CLIENT_SECRET, }, auth: { tokenHost: 'https://raindrop.io', authorizePath: '/oauth/authorize', tokenPath: '/oauth/access_token', }, }); const transports: Record<string, StreamableHTTPServerTransport> = {}; const sseTransports: Record<string, SSEServerTransport> = {}; // Create native HTTP server const server = http.createServer(async (req, res) => { try { const url = parseUrl(req.url || '', true); // CORS handling res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Session-Id'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // Routing if (url.pathname === '/auth/raindrop' && req.method === 'GET') { if (!process.env.RAINDROP_CLIENT_ID) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('RAINDROP_CLIENT_ID not set'); return; } const authorizationUri = oauthClient.authorizeURL({ redirect_uri: RAINDROP_REDIRECT_URI, scope: 'read write', }); res.writeHead(302, { Location: authorizationUri }); res.end(); return; } if (url.pathname === '/auth/raindrop/callback' && req.method === 'GET') { const code = url.query.code as string | undefined; if (!code) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing code parameter'); return; } try { const tokenParams = { code, redirect_uri: RAINDROP_REDIRECT_URI }; const accessToken = await oauthClient.getToken(tokenParams as any); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ access_token: accessToken.token.access_token })); } catch (error: any) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: error.message || 'OAuth token exchange failed' })); } return; } if (url.pathname === '/health' && req.method === 'GET') { const sessions = Array.from(sessionMetadata.values()); const streamableSessions = sessions.filter(s => s.type !== 'sse'); const sseSessions = sessions.filter(s => s.type === 'sse'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'healthy', service: 'raindrop-mcp-optimized', version: '2.0.0', port: PORT, activeSessions: sessions.length, sessionTypes: { streamable: streamableSessions.length, sse: sseSessions.length, }, sessions, optimizations: { toolCount: 24, originalToolCount: 37, reduction: '35%', features: [ 'Consolidated tools with operation parameters', 'AI-friendly descriptions and examples', 'Consistent naming conventions', 'Enhanced parameter documentation', 'Standardized resource URI patterns', 'Improved error handling with suggestions', ], }, transports: { modern: 'StreamableHTTPServerTransport (/mcp endpoint)', legacy: 'SSEServerTransport (/sse + /messages endpoints)', }, })); return; } if (url.pathname === '/' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ name: 'Raindrop MCP HTTP Server', version: '2.0.0', description: 'Optimized Model Context Protocol server for Raindrop.io with enhanced AI-friendly tools', endpoints: { '/': 'This documentation', '/health': 'Health check with session info and optimization details', '/mcp': 'MCP protocol endpoint (POST only) - Modern StreamableHTTP transport', '/sse': 'Legacy SSE connection endpoint (GET) - Server-Sent Events transport', '/messages': 'Legacy SSE message endpoint (POST) - Send messages to SSE transport', }, optimizations: { tools: { original: 37, optimized: 24, improvement: '35% reduction in tool count' }, categories: [ 'Collections (7 tools)', 'Bookmarks (6 tools)', 'Tags (2 tools)', 'Highlights (4 tools)', 'User (2 tools)', 'Import/Export (3 tools)', ], features: [ 'Hierarchical tool naming (category_action pattern)', 'Rich contextual descriptions with use cases', 'Comprehensive parameter documentation', 'Smart tool consolidation with operation parameters', 'Standardized resource URI patterns (raindrop://type/scope)', 'Enhanced error messages with actionable suggestions', ], }, usage: { 'MCP Inspector': `npx @modelcontextprotocol/inspector http://localhost:${PORT}/mcp`, 'Direct API': `POST http://localhost:${PORT}/mcp`, 'Legacy SSE': `GET http://localhost:${PORT}/sse + POST http://localhost:${PORT}/messages`, 'Compare with original': `Original server on port 3001, optimized on port ${PORT}`, }, })); return; } // SSE endpoint for legacy clients (GET to establish SSE connection) if (url.pathname === '/sse' && req.method === 'GET') { try { const sessionId = randomUUID(); const transport = new SSEServerTransport('/messages', res, { allowedOrigins: ['*'], // Allow all origins for development enableDnsRebindingProtection: false, // Disable for compatibility }); transport.onclose = () => { delete sseTransports[sessionId]; sessionMetadata.delete(sessionId); logger.info(`SSE session cleaned up: ${sessionId}`); }; // Store the transport sseTransports[sessionId] = transport; sessionMetadata.set(sessionId, { id: sessionId, type: 'sse', created: new Date().toISOString(), uptime: 0 }); // Connect to MCP server first, then start the SSE connection await mcpServer.connect(transport); logger.info(`SSE session established: ${sessionId}`); } catch (error) { logger.error('Error establishing SSE connection:', error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to establish SSE connection' })); } } return; } // Messages endpoint for SSE transport (POST to send messages) if (url.pathname === '/messages' && req.method === 'POST') { // Collect body const chunks: Uint8Array[] = []; for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); const raw = Buffer.concat(chunks).toString('utf8'); let body: any = undefined; try { body = raw ? JSON.parse(raw) : undefined; } catch (err) { logger.warn('Invalid JSON body on /messages'); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON body' })); return; } try { // Find the SSE transport by session ID in headers or body const sessionId = req.headers['mcp-session-id'] as string || body?.sessionId; const transport = sessionId ? sseTransports[sessionId] : null; if (!transport) { // If no specific session, try to route to any available SSE transport const availableTransports = Object.values(sseTransports); if (availableTransports.length === 0) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active SSE session found' })); return; } // Use the first available transport const fallbackTransport = availableTransports[0]; if (!fallbackTransport) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active SSE session found' })); return; } await fallbackTransport.handlePostMessage(req, res, body); } else { await transport.handlePostMessage(req, res, body); } logger.debug(`SSE message handled for session: ${sessionId || 'auto-routed'}`); } catch (error) { logger.error('Error handling SSE message:', error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to handle SSE message' })); } } return; } // MCP endpoint handling (POST only in original) if (url.pathname === '/mcp') { // Collect body const chunks: Uint8Array[] = []; for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); const raw = Buffer.concat(chunks).toString('utf8'); let body: any = undefined; try { body = raw ? JSON.parse(raw) : undefined; } catch (err) { logger.warn('Invalid JSON body on /mcp'); } try { const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { transport = transports[sessionId]; logger.debug(`Reusing optimized session: ${sessionId}`); } else if (!sessionId && req.method === 'POST' && isInitializeRequest(body)) { logger.info('Creating new optimized Streamable HTTP session'); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { transports[sessionId] = transport; sessionMetadata.set(sessionId, { id: sessionId, created: new Date().toISOString(), uptime: 0 }); logger.info(`New optimized Streamable HTTP session initialized: ${sessionId}`); }, }); transport.onclose = () => { if (transport.sessionId) { delete transports[transport.sessionId]; sessionMetadata.delete(transport.sessionId); logger.info(`Optimized Streamable HTTP session cleaned up: ${transport.sessionId}`); } }; await mcpServer.connect(transport); } else { logger.warn('Invalid optimized MCP request: missing session ID or invalid initialization'); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided or missing initialization' }, id: null })); return; } // delegate to transport await transport.handleRequest(req as any, res as any, body); } catch (error) { logger.error('Error handling optimized Streamable HTTP request:', error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })); } } return; } // Not found res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not Found' })); } catch (err) { logger.error('Server error:', err); try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Internal Server Error' })); } catch { } } }); // Provide a minimal listen function for compatibility app.listen = (port: number, cb?: () => void) => server.listen(port, cb); // Instantiate the shared MCP service and get the server instance const raindropMCP = new RaindropMCPService(); const mcpServer = raindropMCP.getServer(); const cleanup = raindropMCP.cleanup.bind(raindropMCP); /** * MCP protocol endpoint with session management and transport handling. */ /** * Starts the MCP HTTP server and logs endpoints. */ const serverInstance = server.listen(PORT, () => { logger.info(`Optimized Raindrop MCP HTTP Server running on port ${PORT}`); logger.info(`MCP Inspector: npx @modelcontextprotocol/inspector http://localhost:${PORT}/mcp`); logger.info(`Health check: http://localhost:${PORT}/health`); logger.info(`Documentation: http://localhost:${PORT}/`); logger.info(`Modern transport: http://localhost:${PORT}/mcp (StreamableHTTP)`); logger.info(`Legacy transport: http://localhost:${PORT}/sse + http://localhost:${PORT}/messages (SSE)`); logger.info(`Optimizations: 24 tools (vs 37 original), enhanced AI-friendly interface`); }); /** * Graceful shutdown handler for SIGINT. */ process.on('SIGINT', async () => { logger.info('Shutting down optimized HTTP server...'); // Close all active sessions logger.info(`Closing ${Object.keys(transports).length} streamable sessions and ${Object.keys(sseTransports).length} SSE sessions`); // Clean up streamable transports Object.values(transports).forEach(transport => { try { transport.close(); } catch (error) { logger.error('Error closing streamable transport:', error); } }); // Clean up SSE transports Object.values(sseTransports).forEach(transport => { try { transport.close(); } catch (error) { logger.error('Error closing SSE transport:', error); } }); sessionMetadata.clear(); // Close server serverInstance.close(() => { logger.info('Optimized HTTP server stopped'); process.exit(0); }); }); export { activeSessions, server as app, transports, sseTransports };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adeze/raindrop-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server