UNPKG

@grebyn/toolflow-mcp-server

Version:

MCP server for managing other MCP servers - discover, install, organize into bundles, and automate with workflows. Uses StreamableHTTP transport with dual OAuth/API key authentication.

445 lines 20.1 kB
#!/usr/bin/env node import express from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest, CallToolRequestSchema, ListToolsRequestSchema, InitializeRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; import { OAuthEndpointsHandler } from './auth/oauth-endpoints.js'; import { TokenPassthrough } from './auth/token-passthrough.js'; import { listTools, getTool } from './tools/index.js'; import { getServerConfig } from './config/server-config.js'; import { withLogging } from './utils/logger.js'; import { extractClientIdentifier } from './utils/shared-logging.js'; // MCP server with StreamableHTTP transport and OAuth support // Create MCP server factory function with auth context access function createMcpServer(getAuthContext, getRequestContext) { const server = new Server({ name: 'toolflow-mcp', version: '1.0.20', }, { capabilities: { tools: {}, }, }); // Register handlers for MCP protocol server.setRequestHandler(InitializeRequestSchema, async (request) => { console.error(`MCP: Handling Initialize request from ${request.params.clientInfo?.name || 'unknown'}`); return { protocolVersion: request.params.protocolVersion, capabilities: { tools: {}, logging: {}, }, serverInfo: { name: 'toolflow-mcp', version: '1.0.20', } }; }); server.setRequestHandler(ListToolsRequestSchema, async () => { console.error('MCP: Handling ListTools request'); return { tools: listTools() }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; console.error(`MCP: Handling CallTool request for tool: ${toolName}`); const tool = getTool(toolName); if (!tool) { throw new Error(`Tool not found: ${toolName}`); } // Get authenticated user context from the HTTP request const authContext = getAuthContext(); const userContext = { userId: authContext?.userId || 'anonymous', organizationId: authContext?.organizationId, email: authContext?.email, sessionId: authContext?.sessionId, tokenJti: authContext?.tokenJti, token: authContext?.token, apiKey: authContext?.apiKey }; console.error(`Tool '${toolName}' called by user: ${userContext.userId}`); // Get request context for logging const requestContext = getRequestContext(); try { // Execute tool with logging const result = await withLogging(userContext, toolName, request.params.arguments || {}, 'streamable-http', { sessionId: userContext.sessionId, clientIdentifier: requestContext?.clientIdentifier, ipAddress: requestContext?.ipAddress, userAgent: requestContext?.userAgent }, () => tool.execute(request.params.arguments || {}, userContext)); return result; } catch (error) { throw new Error(`Tool execution failed: ${error.message}`); } }); return server; } async function main() { const serverConfig = getServerConfig(); console.error('SERVER CONFIG:', JSON.stringify(serverConfig, null, 2)); console.error('TRANSPORT: streamable-http'); const oauthHandler = new OAuthEndpointsHandler(serverConfig); const tokenPassthrough = new TokenPassthrough(serverConfig.apiEndpoint); const app = express(); app.use(express.json()); // Basic request logging for all requests app.use((req, res, next) => { console.error(`=== INCOMING REQUEST ===`); console.error(`${req.method} ${req.url}`); console.error(`Headers:`, JSON.stringify(req.headers, null, 2)); if (req.body && Object.keys(req.body).length > 0) { console.error(`Body:`, JSON.stringify(req.body, null, 2)); } console.error(`========================`); next(); }); // Configure CORS to expose Mcp-Session-Id header app.use(cors({ origin: '*', exposedHeaders: ['Mcp-Session-Id'] })); // Handle OAuth metadata endpoints (must be public, no auth middleware) app.get('/.well-known/oauth-protected-resource', (req, res) => { console.error('OAuth: Serving protected resource metadata'); oauthHandler.handleProtectedResourceMetadata(req, res); }); app.get('/.well-known/oauth-authorization-server', (req, res) => { console.error('OAuth: Serving authorization server metadata'); oauthHandler.handleAuthorizationServerMetadata(req, res); }); app.post('/.well-known/oauth-dynamic-client-registration', (req, res) => { console.error('OAuth: Handling dynamic client registration'); oauthHandler.handleDynamicClientRegistration(req, res); }); // API key validation function const validateApiKey = async (apiKey) => { if (!apiKey || !apiKey.startsWith('tfl_')) { return { isValid: false, error: 'Invalid API key format' }; } try { // Hash the provided key to match against database const encoder = new TextEncoder(); const data = encoder.encode(apiKey); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const keyHash = Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); // Validate against our API endpoint const response = await fetch(`${serverConfig.apiEndpoint}/proxy`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ operation: 'validateApiKey', params: { keyHash } }) }); if (!response.ok) { const errorText = await response.text(); return { isValid: false, error: `API validation failed: ${errorText}` }; } const result = await response.json(); if (result.is_valid) { return { isValid: true, userId: result.user_id, organizationId: result.organization_id, apiKey: apiKey }; } else { return { isValid: false, error: 'Invalid or expired API key' }; } } catch (error) { return { isValid: false, error: `API key validation error: ${error.message}` }; } }; // Authentication middleware - supports both OAuth and API keys const authMiddleware = async (req, res, next) => { console.error(`HTTP: ${req.method} ${req.path} - Headers: ${JSON.stringify(req.headers)}`); if (serverConfig.disableAuth) { req.auth = { token: null, userId: 'dev-user' }; console.error('AUTH: Using dev-user (auth disabled)'); return next(); } // Check for API key first const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer tfl_')) { const apiKey = authHeader.substring(7); console.error('AUTH: Attempting API key authentication'); const validation = await validateApiKey(apiKey); if (validation.isValid) { req.auth = { token: null, apiKey: validation.apiKey, isAuthenticated: true, userId: validation.userId, organizationId: validation.organizationId }; console.error(`AUTH: API key success - User: ${req.auth.userId}`); return next(); } else { console.error(`AUTH: API key failed - ${validation.error}`); return res.status(401).json({ error: validation.error }); } } // Fallback to OAuth authentication console.error('AUTH: Attempting OAuth authentication'); const authContext = await tokenPassthrough.extractToken(req); if (!authContext.isAuthenticated) { const errorMsg = authContext.error || 'OAuth authentication required'; console.error(`AUTH: OAuth failed - ${errorMsg}`); tokenPassthrough.sendUnauthorizedResponse(req, res, new Error(errorMsg)); return; } req.auth = { token: authContext.token || null, isAuthenticated: authContext.isAuthenticated, userId: authContext.userId, email: authContext.email, organizationId: authContext.organizationId, sessionId: authContext.sessionId, tokenJti: authContext.tokenJti }; console.error(`AUTH: OAuth success - User: ${req.auth.userId}`); next(); }; // Map to store transports and their contexts by session ID const transports = {}; // Session cleanup configuration const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes // Periodic cleanup of expired sessions const cleanupInterval = setInterval(() => { const now = Date.now(); const expiredSessions = []; for (const [sessionId, session] of Object.entries(transports)) { if (now - session.lastUsed > SESSION_TTL_MS) { expiredSessions.push(sessionId); } } for (const sessionId of expiredSessions) { try { console.error(`Cleaning up expired session: ${sessionId}`); const session = transports[sessionId]; if (session?.transport) { session.transport.close().catch(() => { }); // Silent cleanup } delete transports[sessionId]; } catch (error) { // Silent cleanup - don't let cleanup errors break the server } } if (expiredSessions.length > 0) { console.error(`Cleaned up ${expiredSessions.length} expired sessions`); } }, CLEANUP_INTERVAL_MS); // MCP POST endpoint const mcpPostHandler = async (req, res) => { const sessionId = req.headers['mcp-session-id']; try { if (sessionId && transports[sessionId]) { // Reuse existing transport with stored auth context const transportData = transports[sessionId]; // Update auth context for this request (in case token was refreshed) transportData.authContext = req.auth; // Update request context transportData.requestContext = { ipAddress: req.ip || req.socket.remoteAddress, userAgent: req.headers['user-agent'], clientIdentifier: extractClientIdentifier(req.headers['user-agent']) }; // Update last used timestamp for TTL cleanup transportData.lastUsed = Date.now(); console.error(`MCP: Handling request for existing session ${sessionId}`); await transportData.transport.handleRequest(req, res, req.body); return; } if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - create fresh transport and server console.error('MCP: Processing new initialization request'); console.error('MCP: Request body:', JSON.stringify(req.body)); const eventStore = new InMemoryEventStore(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, onsessioninitialized: (newSessionId) => { console.error(`MCP: Session initialized with ID: ${newSessionId} for user: ${req.auth?.userId || 'anonymous'}`); // Store transport with auth and request context transports[newSessionId] = { transport, authContext: req.auth, requestContext: { ipAddress: req.ip || req.socket.remoteAddress, userAgent: req.headers['user-agent'], clientIdentifier: extractClientIdentifier(req.headers['user-agent']) }, lastUsed: Date.now() // Initialize timestamp for TTL cleanup }; }, onsessionclosed: (closedSessionId) => { console.error(`MCP: Session closed: ${closedSessionId}`); delete transports[closedSessionId]; } }); // Set up cleanup on transport close transport.onclose = () => { const sid = transport.sessionId; if (sid && transports[sid]) { console.error(`MCP: Transport closed for session ${sid}`); delete transports[sid]; } }; // Create auth context accessor that reads from the stored session data const getAuthContext = () => { const sid = transport.sessionId; if (sid && transports[sid]) { const authContext = transports[sid].authContext; // Ensure sessionId is available for logging return { ...authContext, sessionId: sid }; } return req.auth; // Fallback during initialization }; // Create request context accessor const getRequestContext = () => { const sid = transport.sessionId; if (sid && transports[sid]) { return transports[sid].requestContext; } // Fallback during initialization return { ipAddress: req.ip || req.socket.remoteAddress, userAgent: req.headers['user-agent'], clientIdentifier: extractClientIdentifier(req.headers['user-agent']) }; }; // Create server and connect to transport console.error('MCP: Creating server and connecting transport'); const server = createMcpServer(getAuthContext, getRequestContext); // Connect server to transport first await server.connect(transport); console.error('MCP: Server connected successfully'); // Now handle the initialization request console.error('MCP: Handling initialization request'); await transport.handleRequest(req, res, req.body); console.error('MCP: Initialization request handled'); return; } // Invalid request - no session ID and not an initialization request console.error('MCP: Invalid request - no session ID and not initialization'); res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided and not an initialization request', }, id: null, }); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); } } }; // MCP GET endpoint for SSE const mcpGetHandler = async (req, res) => { const sessionId = req.headers['mcp-session-id']; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); return; } const transportData = transports[sessionId]; // Update last used timestamp transportData.lastUsed = Date.now(); await transportData.transport.handleRequest(req, res); }; // MCP DELETE endpoint for session termination const mcpDeleteHandler = async (req, res) => { const sessionId = req.headers['mcp-session-id']; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); return; } console.error(`Session termination request for session ${sessionId}`); try { const transportData = transports[sessionId]; // Update last used timestamp before handling termination transportData.lastUsed = Date.now(); await transportData.transport.handleRequest(req, res); } catch (error) { console.error('Error handling session termination:', error); if (!res.headersSent) { res.status(500).send('Error processing session termination'); } } }; // Set up MCP routes with auth middleware app.post('/mcp', authMiddleware, mcpPostHandler); app.get('/mcp', authMiddleware, mcpGetHandler); app.delete('/mcp', authMiddleware, mcpDeleteHandler); // Start server with fixed port for mcp-remote compatibility const port = process.env.PORT || 3000; // Fixed port for Claude Desktop + mcp-remote const server = app.listen(port, () => { const actualPort = server.address()?.port || port; console.error(`ToolFlow MCP Server started on port ${actualPort} with StreamableHTTP transport`); }); // Health check endpoint app.get('/', (req, res) => { const actualPort = server?.address()?.port || 'unknown'; res.json({ message: 'ToolFlow MCP Server', transport: 'StreamableHTTP', version: '1.0.20', port: actualPort, endpoints: { mcp: '/mcp', oauth_discovery: '/.well-known/oauth-protected-resource' } }); }); // Handle graceful shutdown process.on('SIGINT', async () => { console.error('Shutting down server...'); // Clear the cleanup interval clearInterval(cleanupInterval); // Close all active sessions for (const sessionId in transports) { try { console.error(`Closing transport for session ${sessionId}`); await transports[sessionId].transport.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); } } console.error('Server shutdown complete'); process.exit(0); }); } // Run the server main().catch(error => { console.error("Server error:", error); process.exit(1); }); //# sourceMappingURL=index-streamable-http.js.map