Skip to main content
Glama
IAmAlexander

Readwise MCP Server

by IAmAlexander
server.ts37.2 kB
// Third-party imports import express from 'express'; import type { Express, Request, Response } from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; import { createServer } from 'http'; import type { Server as HttpServer } from 'http'; import { createRequire } from 'module'; // Load package.json for version info (ES modules compatible) const require = createRequire(import.meta.url); const packageJson = require('../package.json') as { name: string; version: string }; // MCP SDK imports - need .js extension for runtime imports import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js'; import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; // Local type imports - no .js extension import type { MCPRequest, MCPResponse, ErrorResponse, ErrorType, TransportType } from './types/index.js'; import type { ValidationResult, ValidationError } from './types/validation.js'; // Local implementation imports - need .js extension import { ReadwiseClient } from './api/client.js'; import { ReadwiseAPI } from './api/readwise-api.js'; import { BaseMCPTool } from './mcp/registry/base-tool.js'; import { BaseMCPPrompt } from './mcp/registry/base-prompt.js'; import { ToolRegistry } from './mcp/registry/tool-registry.js'; import { PromptRegistry } from './mcp/registry/prompt-registry.js'; import type { Logger } from './utils/logger-interface.js'; import { getConfig } from './utils/config.js'; // Tool imports - need .js extension import { GetBooksTool } from './tools/get-books.js'; import { GetHighlightsTool } from './tools/get-highlights.js'; import { GetDocumentsTool } from './tools/get-documents.js'; import { SearchHighlightsTool } from './tools/search-highlights.js'; import { GetTagsTool } from './tools/get-tags.js'; import { DocumentTagsTool } from './tools/document-tags.js'; import { BulkTagsTool } from './tools/bulk-tags.js'; import { GetReadingProgressTool } from './tools/get-reading-progress.js'; import { UpdateReadingProgressTool } from './tools/update-reading-progress.js'; import { GetReadingListTool } from './tools/get-reading-list.js'; import { CreateHighlightTool } from './tools/create-highlight.js'; import { UpdateHighlightTool } from './tools/update-highlight.js'; import { DeleteHighlightTool } from './tools/delete-highlight.js'; import { CreateNoteTool } from './tools/create-note.js'; import { AdvancedSearchTool } from './tools/advanced-search.js'; import { SearchByTagTool } from './tools/search-by-tag.js'; import { SearchByDateTool } from './tools/search-by-date.js'; import { GetVideosTool } from './tools/get-videos.js'; import { GetVideoTool } from './tools/get-video.js'; import { CreateVideoHighlightTool } from './tools/create-video-highlight.js'; import { GetVideoHighlightsTool } from './tools/get-video-highlights.js'; import { UpdateVideoPositionTool } from './tools/update-video-position.js'; import { GetVideoPositionTool } from './tools/get-video-position.js'; import { SaveDocumentTool } from './tools/save-document.js'; import { UpdateDocumentTool } from './tools/update-document.js'; import { DeleteDocumentTool } from './tools/delete-document.js'; import { GetRecentContentTool } from './tools/get-recent-content.js'; import { BulkSaveDocumentsTool } from './tools/bulk-save-documents.js'; import { BulkUpdateDocumentsTool } from './tools/bulk-update-documents.js'; import { BulkDeleteDocumentsTool } from './tools/bulk-delete-documents.js'; // Prompt imports - need .js extension import { ReadwiseHighlightPrompt } from './prompts/highlight-prompt.js'; import { ReadwiseSearchPrompt } from './prompts/search-prompt.js'; /** * Readwise MCP Server implementation */ export class ReadwiseMCPServer { private app: Express; private server: HttpServer; private mcpServer: MCPServer; private port: number; private apiClient: ReadwiseClient; private api: ReadwiseAPI; private toolRegistry: ToolRegistry; private promptRegistry: PromptRegistry; private logger: Logger; private transportType: TransportType; private startTime: number; private isReady: boolean = false; /** * Create a new Readwise MCP server * @param apiKey - Readwise API key * @param port - Port to listen on (default: 3000) * @param logger - Logger instance * @param transport - Transport type (default: stdio) */ constructor( apiKey: string = '', port: number = 3000, logger: Logger, transport: TransportType = 'stdio', baseUrl?: string ) { // Assign logger first so it's available in error handling this.logger = logger; try { // Check if running under MCP Inspector const isMCPInspector = process.env.MCP_INSPECTOR === 'true' || process.argv.includes('--mcp-inspector') || process.env.NODE_ENV === 'mcp-inspector'; // When running under inspector: // - Use port 3000 (required for inspector's proxy) // - Force SSE transport this.port = isMCPInspector ? 3000 : port; this.transportType = isMCPInspector ? 'sse' : transport; this.startTime = Date.now(); // Initialize API client (allow empty API key for lazy loading) // API key will be validated when tools are actually called this.apiClient = new ReadwiseClient({ apiKey: apiKey || '', baseUrl }); this.api = new ReadwiseAPI(this.apiClient); // Initialize registries this.toolRegistry = new ToolRegistry(this.logger); this.promptRegistry = new PromptRegistry(this.logger); // Initialize Express app this.app = express(); this.app.use(bodyParser.json()); // Configure CORS - allow all origins for Smithery and other hosted deployments // In production, you can restrict this via CORS_ALLOWED_ORIGINS environment variable const corsEnabled = process.env.CORS_ENABLED !== 'false'; const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(o => o.trim()) : null; // null means allow all origins if (corsEnabled) { // Configure CORS according to Smithery requirements // See: https://smithery.ai/docs/build/deployments/containers#how-do-i-set-up-cors-handling this.app.use(cors({ origin: (origin, callback) => { // Allow requests with no origin (like mobile apps, curl, or health checks) if (!origin) return callback(null, true); // If no specific origins configured, allow all (for Smithery and hosted deployments) if (!allowedOrigins) { return callback(null, true); } if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', '*'], // Allow all headers as per Smithery docs exposedHeaders: ['mcp-session-id', 'mcp-protocol-version'], // Expose MCP-specific headers credentials: true })); } this.server = createServer(this.app); // Register tools and prompts BEFORE creating MCP Server // so capabilities can be properly initialized this.registerTools(); this.registerPrompts(); // Initialize MCP Server with actual capabilities this.mcpServer = new MCPServer({ name: packageJson.name, version: packageJson.version }, { capabilities: { tools: this.toolRegistry.getNames().reduce((acc, name) => ({ ...acc, [name]: true }), {}), prompts: this.promptRegistry.getNames().reduce((acc, name) => ({ ...acc, [name]: true }), {}) } }); // Register tools with SDK ServerTools plugin so they are properly exposed this.registerToolsWithSDK(); // Set up routes BEFORE starting the server this.setupRoutes(); // Set up SSE transport routes BEFORE starting the server // (SSE endpoint setup happens here, actual transport connection happens on /sse request) this.setupSSERoutes(); this.logger.debug('Server constructor completed successfully'); } catch (error) { this.logger.error('Error in server constructor:', error as any); // Re-throw to prevent server from starting in an invalid state throw error; } } /** * Register MCP tools */ private registerTools(): void { this.logger.debug('Registering tools'); // All tool classes - instantiate and register in one pass const toolClasses = [ // Core tools GetHighlightsTool, GetBooksTool, GetDocumentsTool, SearchHighlightsTool, GetTagsTool, DocumentTagsTool, BulkTagsTool, GetReadingProgressTool, UpdateReadingProgressTool, GetReadingListTool, // Highlight management CreateHighlightTool, UpdateHighlightTool, DeleteHighlightTool, CreateNoteTool, // Search tools AdvancedSearchTool, SearchByTagTool, SearchByDateTool, // Video tools GetVideosTool, GetVideoTool, CreateVideoHighlightTool, GetVideoHighlightsTool, UpdateVideoPositionTool, GetVideoPositionTool, // Document management tools SaveDocumentTool, UpdateDocumentTool, DeleteDocumentTool, GetRecentContentTool, // Bulk document operation tools BulkSaveDocumentsTool, BulkUpdateDocumentsTool, BulkDeleteDocumentsTool, ]; // Instantiate and register all tools for (const ToolClass of toolClasses) { this.toolRegistry.register(new ToolClass(this.api, this.logger)); } this.logger.info(`Registered ${this.toolRegistry.getNames().length} tools`); } /** * Register MCP prompts */ private registerPrompts(): void { this.logger.debug('Registering prompts'); // Create prompts const highlightPrompt = new ReadwiseHighlightPrompt(this.api, this.logger); const searchPrompt = new ReadwiseSearchPrompt(this.api, this.logger); // Register prompts this.promptRegistry.register(highlightPrompt); this.promptRegistry.register(searchPrompt); this.logger.info(`Registered ${this.promptRegistry.getNames().length} prompts`); } /** * Set up SDK request handlers for tool execution * Tools are discovered via capabilities, SDK routes calls to our handlers */ private registerToolsWithSDK(): void { try { this.logger.debug('Setting up SDK request handlers for tools'); // Set up request handler for tools/list this.mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { const tools = this.toolRegistry.getNames().map(toolName => { const tool = this.toolRegistry.get(toolName); return { name: tool?.name || toolName, description: tool?.description || '', inputSchema: tool?.parameters || {} }; }); return { tools }; }); // Set up request handler for tools/call this.mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; this.logger.debug(`SDK tool call received: ${name}`, { args }); // Get the tool from our registry const tool = this.toolRegistry.get(name); if (!tool) { throw new Error(`Tool not found: ${name}`); } // Execute the tool using our existing implementation const result = await tool.execute(args || {}); return result; }); this.logger.info(`Set up SDK request handlers for ${this.toolRegistry.getNames().length} tools`); } catch (error) { this.logger.error('Error setting up SDK request handlers:', error as any); // Don't throw - allow server to continue even if handler setup fails // Tools will still work through our custom handleMCPRequest method } } /** * Start the server */ async start(): Promise<void> { return new Promise<void>((resolve, reject) => { this.logger.info('Starting HTTP server...'); this.logger.info(`Binding to 0.0.0.0:${this.port}`); this.logger.debug('Routes are already set up in constructor'); // Handle server errors BEFORE listening this.server.on('error', (error: NodeJS.ErrnoException) => { this.logger.error('Server error event:', error); this.logger.error('Error code:', error.code); this.logger.error('Error message:', error.message); if (error.code === 'EADDRINUSE') { reject(new Error(`Port ${this.port} is already in use`)); } else { reject(error); } }); // Log when server starts listening this.server.listen(this.port, '0.0.0.0', () => { const address = this.server.address(); this.logger.info(`✓ Server started successfully on port ${this.port}`); this.logger.info(`✓ Listening on ${typeof address === 'string' ? address : `${address?.address}:${address?.port}`}`); this.logger.info(`✓ Transport type: ${this.transportType}`); this.logger.info(`✓ Startup time: ${Date.now() - this.startTime}ms`); this.logger.info(`✓ Health check: http://0.0.0.0:${this.port}/health`); this.logger.info(`✓ Tools registered: ${this.toolRegistry.getNames().length}`); this.logger.info(`✓ Prompts registered: ${this.promptRegistry.getNames().length}`); // If using stdio transport, set up stdin handler if (this.transportType === 'stdio') { this.logger.debug('Setting up stdio transport...'); this.setupStdioTransport(); this.logger.debug('Stdio transport configured'); } // SSE routes are already set up in constructor via setupSSERoutes() // Mark server as ready this.isReady = true; this.logger.info('✓ Server initialization complete - ready to accept connections'); resolve(); }); // Also log when server is listening (additional confirmation) this.server.on('listening', () => { const address = this.server.address(); this.logger.debug('Server listening event fired', { address }); }); }); } /** * Stop the server */ async stop(): Promise<void> { return new Promise<void>((resolve, reject) => { this.server.close((err) => { if (err) { this.logger.error('Error stopping server', err); reject(err); } else { this.logger.info('Server stopped'); resolve(); } }); }); } /** * Set up routes for the server */ private setupRoutes(): void { this.logger.debug('Setting up routes'); // Root endpoint for basic connectivity check this.app.get('/', (_req: Request, res: Response) => { res.json({ name: packageJson.name, version: packageJson.version, status: 'running', transport: this.transportType, endpoints: { health: '/health', capabilities: '/capabilities', sse: '/sse', mcp: '/mcp' } }); }); // Health check endpoint - must be accessible without authentication // This is critical for Smithery deployment - must respond immediately this.app.get('/health', (_req: Request, res: Response) => { try { const health = { status: this.isReady ? 'ok' : 'starting', ready: this.isReady, uptime: process.uptime(), transport: this.transportType, tools: this.toolRegistry.getNames().length, prompts: this.promptRegistry.getNames().length, timestamp: new Date().toISOString(), port: this.port }; // Always return 200, but indicate readiness status const statusCode = this.isReady ? 200 : 503; this.logger.debug('Health check requested', { ...health, statusCode }); res.status(statusCode).json(health); } catch (error) { this.logger.error('Error in health check:', error); res.status(500).json({ status: 'error', ready: false, message: error instanceof Error ? error.message : 'Unknown error' }); } }); // OAuth metadata endpoint - Smithery checks this during deployment // We use API key auth, not OAuth, so return 404 to indicate OAuth not supported this.app.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => { this.logger.debug('OAuth metadata requested - not supported (using API key auth)'); res.status(404).json({ error: 'oauth_not_supported', message: 'This server uses API key authentication, not OAuth' }); }); // Capabilities endpoint this.app.get('/capabilities', (_req: Request, res: Response) => { res.json({ version: packageJson.version, transports: ['sse'], tools: this.toolRegistry.getNames().map(name => { const tool = this.toolRegistry.get(name); return { name, description: tool?.description || '', parameters: tool?.parameters || {} }; }), prompts: this.promptRegistry.getNames().map(name => { const prompt = this.promptRegistry.get(name); return { name, description: prompt?.description || '', parameters: prompt?.parameters || {} }; }) }); }); // Handle OPTIONS preflight requests for /mcp endpoint // Required for CORS preflight checks from Smithery this.app.options('/mcp', (_req: Request, res: Response) => { this.logger.debug('OPTIONS preflight request for /mcp'); // CORS middleware should handle this, but ensure headers are set res.status(204).end(); }); // Store active Streamable HTTP transports by session ID const streamableTransports = new Map<string, StreamableHTTPServerTransport>(); // MCP HTTP endpoint for Smithery and other HTTP-based clients // Uses Streamable HTTP transport from MCP SDK for proper protocol compliance this.app.all('/mcp', async (req: Request, res: Response) => { try { this.logger.debug('MCP Streamable HTTP request received', { method: req.method, path: req.path, headers: Object.keys(req.headers) }); // Ensure MCP server is initialized if (!this.mcpServer) { this.logger.error('MCP server not initialized'); res.status(503).json({ error: { type: 'transport', details: { code: 'server_not_ready', message: 'MCP server is not initialized yet' } } }); return; } const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId && streamableTransports.has(sessionId)) { // Reuse existing transport for the session const existingTransport = streamableTransports.get(sessionId); if (!existingTransport) { throw new Error(`Transport not found for session: ${sessionId}`); } transport = existingTransport; this.logger.debug(`Reusing transport for session: ${sessionId}`); } else { // Create a new transport for a new session this.logger.debug('Creating new Streamable HTTP transport'); const { randomUUID } = await import('crypto'); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); // Connect transport to MCP server // SDK handles all JSON-RPC messages including initialize and list_tools this.logger.debug('Connecting transport to MCP server'); await this.mcpServer.connect(transport); this.logger.debug('Transport connected successfully'); // Store transport by session ID after connection const newSessionId = (transport as any).sessionId; if (newSessionId) { streamableTransports.set(newSessionId, transport); this.logger.info(`Created new Streamable HTTP transport (session: ${newSessionId})`); // Set session ID header for client res.setHeader('mcp-session-id', newSessionId); } else { this.logger.warn('Streamable HTTP transport created but sessionId not available'); } } // Handle the request through the transport this.logger.debug('Handling request through transport'); await transport.handleRequest(req, res, req.body); this.logger.debug('Request handled successfully'); } catch (error) { this.logger.error('Error handling MCP Streamable HTTP request:', error as any); if (error instanceof Error) { this.logger.error('Error stack:', error.stack); } if (!res.headersSent) { res.status(500).json({ error: { type: 'transport', details: { code: 'server_error', message: error instanceof Error ? error.message : 'Unknown error' } } }); } } }); } /** * Set up stdio transport */ private setupStdioTransport(): void { this.logger.debug('Setting up stdio transport'); process.stdin.setEncoding('utf8'); process.stdin.on('data', (data: Buffer) => { try { const input = data.toString().trim(); if (!input) return; // Parse the request const request = JSON.parse(input) as MCPRequest; // Handle the request this.handleMCPRequest(request, (response) => { // Write the response to stdout process.stdout.write(JSON.stringify(response) + '\n'); }); } catch (error) { this.logger.error('Error handling stdin data', error); // Write error response to stdout const errorResponse: ErrorResponse = { error: { type: 'transport' as ErrorType, details: { code: 'invalid_request', message: error instanceof Error ? error.message : 'Invalid request' } }, request_id: 'unknown' // Unknown request_id for parsing errors }; process.stdout.write(JSON.stringify(errorResponse) + '\n'); } }); this.logger.info('Listening for requests on stdin'); } /** * Validate that a request follows the MCP protocol format * @param request - The request to validate * @returns True if the request is valid, false otherwise */ private validateMCPRequest(request: any): { valid: boolean; error?: string } { // Check if request is an object if (!request || typeof request !== 'object') { return { valid: false, error: 'Request must be a JSON object' }; } // Check if request has required fields if (!('type' in request)) { return { valid: false, error: 'Missing required field: type' }; } if (!('name' in request)) { return { valid: false, error: 'Missing required field: name' }; } if (!('request_id' in request)) { return { valid: false, error: 'Missing required field: request_id' }; } // Validate request type if (request.type !== 'tool_call' && request.type !== 'prompt_call') { return { valid: false, error: `Invalid request type: ${request.type}. Must be 'tool_call' or 'prompt_call'` }; } // Validate request name if (typeof request.name !== 'string' || request.name.trim() === '') { return { valid: false, error: 'Invalid request name: must be a non-empty string' }; } // Validate request_id if (typeof request.request_id !== 'string' || request.request_id.trim() === '') { return { valid: false, error: 'Invalid request_id: must be a non-empty string' }; } // Validate parameters if (!('parameters' in request) || typeof request.parameters !== 'object') { return { valid: false, error: 'Missing or invalid parameters: must be an object' }; } return { valid: true }; } /** * Handle an MCP request * @param request - The MCP request * @param callback - Callback function to receive the response */ public handleMCPRequest(request: MCPRequest, callback: (response: MCPResponse | ErrorResponse) => void): void { // Validate the request format const validation = this.validateMCPRequest(request); if (!validation.valid) { this.logger.warn('Invalid MCP request format', { error: validation.error, request }); callback({ error: { type: 'transport', details: { code: 'invalid_request', message: validation.error || 'Invalid request format' } }, request_id: (request as any)?.request_id || 'unknown' }); return; } const requestType = (request as any).type; const requestName = (request as any).name; const requestId = (request as any).request_id; this.logger.debug('Handling MCP request', { type: requestType, name: requestName, request_id: requestId }); // Handle different request types if (requestType === 'tool_call') { this.handleToolCall(request as MCPRequest & { type: 'tool_call' }, callback); } else if (requestType === 'prompt_call') { this.handlePromptCall(request as MCPRequest & { type: 'prompt_call' }, callback); } else { this.logger.warn(`Unknown request type: ${requestType}`); // Return error callback({ error: { type: 'transport', details: { code: 'invalid_request_type', message: `Unknown request type: ${requestType}` } }, request_id: requestId }); } } /** * Handle a tool call * @param request - The tool call request * @param callback - Callback function to receive the response */ private handleToolCall( request: MCPRequest & { type: 'tool_call' }, callback: (response: MCPResponse | ErrorResponse) => void ): void { const { name, parameters, request_id } = request; // Get the tool const tool = this.toolRegistry.get(name); if (!tool) { this.logger.warn(`Tool not found: ${name}`); // Return error callback({ error: { type: 'transport', details: { code: 'tool_not_found', message: `Tool not found: ${name}` } }, request_id }); return; } // Validate parameters const validationResult = tool.validate ? tool.validate(parameters) : { valid: true, success: true, errors: [] }; if (!validationResult.valid) { this.logger.warn(`Invalid parameters for tool ${name}`, { errors: validationResult.errors } as any); const errorResponse: ErrorResponse = { error: { type: 'validation' as ErrorType, details: { code: 'invalid_parameters', message: 'Invalid parameters', errors: validationResult.errors.map(e => `${e.field}: ${e.message}`) } }, request_id: request.request_id }; callback(errorResponse); return; } // Execute the tool (typeof (tool as any).executeAsMCP === 'function' ? (tool as any).executeAsMCP(parameters) : tool.execute(parameters)) .then((result: any) => { this.logger.debug(`Tool ${name} execution successful`); const response: MCPResponse = (result as any).content ? (result as any) : { content: [{ type: 'text', text: JSON.stringify(result) }] }; callback({ ...response, request_id: request.request_id }); }) .catch((error: unknown) => { this.logger.error(`Tool ${name} execution error`, error as any); // Check if it's an authentication error const isAuthError = error && typeof error === 'object' && 'type' in error && error.type === 'authentication'; const errorDetails = error && typeof error === 'object' && 'details' in error ? error.details : null; const errorResponse: ErrorResponse = { error: { type: (isAuthError ? 'validation' : 'execution') as ErrorType, details: { code: errorDetails && typeof errorDetails === 'object' && 'code' in errorDetails ? String(errorDetails.code) : 'tool_error', message: errorDetails && typeof errorDetails === 'object' && 'message' in errorDetails ? String(errorDetails.message) : error instanceof Error ? error.message : 'Unknown error' } }, request_id: request.request_id }; callback(errorResponse); }); } /** * Handle a prompt call * @param request - The prompt call request * @param callback - Callback function to receive the response */ private handlePromptCall( request: MCPRequest & { type: 'prompt_call' }, callback: (response: MCPResponse | ErrorResponse) => void ): void { const { name, parameters, request_id } = request; // Get the prompt const prompt = this.promptRegistry.get(name); if (!prompt) { this.logger.warn(`Prompt not found: ${name}`); // Return error callback({ error: { type: 'transport', details: { code: 'prompt_not_found', message: `Prompt not found: ${name}` } }, request_id }); return; } // Validate parameters const validationResult = prompt.validate ? prompt.validate(parameters) : { valid: true, success: true, errors: [] }; if (!validationResult.valid) { this.logger.warn(`Invalid parameters for prompt ${name}`, { errors: validationResult.errors } as any); const errorResponse: ErrorResponse = { error: { type: 'validation' as ErrorType, details: { code: 'invalid_parameters', message: 'Invalid parameters', errors: validationResult.errors.map(e => `${e.field}: ${e.message}`) } }, request_id: request.request_id }; callback(errorResponse); return; } // Execute the prompt prompt.execute(parameters) .then((result: any) => { this.logger.debug(`Prompt ${name} execution successful`); const response: MCPResponse = (result as any).content ? (result as any) : { content: [{ type: 'text', text: JSON.stringify(result) }] }; callback({ ...response, request_id: request.request_id }); }) .catch((error: unknown) => { this.logger.error(`Prompt ${name} execution error`, error as any); const errorResponse: ErrorResponse = { error: { type: 'execution' as ErrorType, details: { code: 'prompt_error', message: error instanceof Error ? error.message : 'Unknown error' } }, request_id: request.request_id }; callback(errorResponse); }); } /** * Set up SSE transport routes * Uses SDK's SSE transport for proper MCP protocol compliance */ private setupSSERoutes(): void { this.logger.debug('Setting up SSE routes'); // Store active SSE transports by session ID const sseTransports = new Map<string, SSEServerTransport>(); // SSE endpoint for server-to-client streaming this.app.get('/sse', async (req: Request, res: Response) => { try { this.logger.debug('New SSE connection request', { query: req.query, headers: req.headers }); // Set SSE headers res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); res.flushHeaders(); // Create SSE transport instance // The first parameter is the message endpoint path for POST requests const transport = new SSEServerTransport('/messages', res); // Generate session ID and store transport const sessionId = `sse-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; sseTransports.set(sessionId, transport); // Connect transport to MCP server // SDK handles all JSON-RPC messages including initialize and list_tools await transport.start(); await this.mcpServer.connect(transport); this.logger.info(`SSE transport connected to MCP server (session: ${sessionId})`); // Handle client disconnect req.on('close', () => { this.logger.debug(`Client disconnected (session: ${sessionId})`); sseTransports.delete(sessionId); transport.close().catch(err => { this.logger.error('Error closing transport:', err); }); }); // Handle transport errors transport.onerror = (error) => { this.logger.error('Transport error:', error); sseTransports.delete(sessionId); }; transport.onclose = () => { this.logger.debug(`Transport closed (session: ${sessionId})`); sseTransports.delete(sessionId); }; } catch (error) { this.logger.error('Error in SSE endpoint:', error); // Only send error response if headers haven't been sent if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', data: error instanceof Error ? error.message : String(error) } }); } } }); // Message endpoint for SSE clients to send POST requests // This is required by SSEServerTransport for client-to-server communication this.app.post('/messages', async (req: Request, res: Response) => { try { const sessionId = req.query.sessionId as string; if (!sessionId) { this.logger.warn('POST /messages request missing sessionId'); res.status(400).json({ error: { type: 'validation', details: { code: 'missing_session_id', message: 'Missing required query parameter: sessionId' } } }); return; } const transport = sseTransports.get(sessionId); if (!transport) { this.logger.warn(`No transport found for sessionId: ${sessionId}`); res.status(404).json({ error: { type: 'transport', details: { code: 'session_not_found', message: `No active SSE connection found for sessionId: ${sessionId}` } } }); return; } // Handle the POST message through the transport // The SDK's SSEServerTransport should handle JSON-RPC messages automatically // If handlePostMessage exists, use it; otherwise, the SDK handles it via the connected transport if (typeof (transport as any).handlePostMessage === 'function') { await (transport as any).handlePostMessage(req, res, req.body); } else { // The SDK should handle messages automatically through the connected transport // For now, acknowledge receipt - the SDK will process it this.logger.debug('Message received for SSE session, SDK will handle via connected transport'); res.json({ jsonrpc: '2.0', id: req.body?.id || null, result: {} }); } } catch (error) { this.logger.error('Error handling POST /messages:', error as any); if (!res.headersSent) { res.status(500).json({ error: { type: 'transport', details: { code: 'server_error', message: error instanceof Error ? error.message : 'Unknown error' } } }); } } }); } }

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/IAmAlexander/readwise-mcp'

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