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.

400 lines 16.6 kB
#!/usr/bin/env node /** * Stdio-to-HTTP Proxy for ToolFlow MCP Server * * This proxy allows the HTTP streamable server with OAuth to work through stdio transport. * It starts an HTTP server on a fixed port and bridges stdio commands to HTTP requests. */ import { spawn } from 'child_process'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema, InitializeRequestSchema, CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import net from 'net'; import pkceChallenge from 'pkce-challenge'; // Default starting port for automatic port selection const DEFAULT_START_PORT = 3333; let HTTP_PORT; let HTTP_SERVER_URL; // Check if port is available async function isPortAvailable(port) { return new Promise((resolve) => { const tester = net.createServer() .once('error', () => resolve(false)) .once('listening', () => { tester.once('close', () => resolve(true)).close(); }) .listen(port); }); } // Find an available port starting from a base port async function findAvailablePort(startPort = 8080) { for (let port = startPort; port < startPort + 100; port++) { if (await isPortAvailable(port)) { return port; } } throw new Error(`No available ports found in range ${startPort}-${startPort + 99}`); } // Start the HTTP streamable server async function startHttpServer() { // Automatically find an available port starting from DEFAULT_START_PORT HTTP_PORT = await findAvailablePort(DEFAULT_START_PORT); HTTP_SERVER_URL = `http://localhost:${HTTP_PORT}/mcp`; console.error(`Starting ToolFlow HTTP server on port ${HTTP_PORT}...`); // Start the HTTP streamable server as a child process // Get the directory where this script is located const scriptDir = new URL('.', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'); const httpServerScript = `${scriptDir}/index-streamable-http.js`; const httpServer = spawn(process.execPath, [ httpServerScript ], { env: { ...process.env, PORT: HTTP_PORT.toString(), // Pass through any OAuth config TOOLFLOW_OAUTH_ENABLED: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] }); // Pipe HTTP server logs to stderr (for debugging) httpServer.stdout?.on('data', (data) => { const msg = data.toString(); // Only log important messages, not all HTTP logs if (msg.includes('started') || msg.includes('OAuth') || msg.includes('ERROR')) { console.error(`[HTTP Server] ${msg.trim()}`); } }); httpServer.stderr?.on('data', (data) => { console.error(`[HTTP Server Error] ${data.toString().trim()}`); }); httpServer.on('error', (error) => { console.error('Failed to start HTTP server:', error); process.exit(1); }); httpServer.on('exit', (code, signal) => { if (code !== 0 && code !== null) { console.error(`HTTP server exited with code ${code}`); process.exit(code); } }); // Wait for server to be ready await waitForServer(HTTP_PORT); console.error(`ToolFlow HTTP server ready on port ${HTTP_PORT}`); return httpServer; } // Wait for the HTTP server to be ready async function waitForServer(port, maxAttempts = 30) { for (let i = 0; i < maxAttempts; i++) { try { const response = await fetch(`http://localhost:${port}/`); if (response.ok) { return; } } catch { // Server not ready yet } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error('HTTP server failed to start within timeout'); } // Create authenticated HTTP client with Bearer token async function createAuthenticatedHttpClient(httpServerUrl, accessToken) { console.error(`Creating authenticated HTTP client with access token`); // Pass Authorization header directly to the StreamableHTTPClientTransport via requestInit const httpTransport = new StreamableHTTPClientTransport(new URL(httpServerUrl), { requestInit: { headers: { 'Authorization': `Bearer ${accessToken}`, } } }); const httpClient = new Client({ name: 'toolflow-stdio-proxy', version: '1.0.0' }, { capabilities: {} }); console.error(`Connecting to HTTP server with Authorization header`); await httpClient.connect(httpTransport); console.error(`Successfully connected with OAuth token`); return { httpClient, httpTransport }; } // Custom OAuth-aware HTTP client async function createOAuthAwareConnection(httpServerUrl) { console.error('Creating OAuth-aware HTTP bridge...'); let accessToken = null; // Try initial connection try { const httpTransport = new StreamableHTTPClientTransport(new URL(httpServerUrl)); const httpClient = new Client({ name: 'toolflow-stdio-proxy', version: '1.0.0' }, { capabilities: {} }); await httpClient.connect(httpTransport); console.error('Connected to HTTP server directly (no OAuth needed)'); return { httpClient, httpTransport }; } catch (error) { // Check if this is an OAuth 401 error if (error.message.includes('HTTP 401') && error.message.includes('oauth-protected-resource')) { console.error('OAuth authentication required, initiating browser flow...'); // Extract server URL from error message const match = error.message.match(/resource":"([^"]+)"/); if (match) { const discoveryUrl = match[1]; console.error(`Discovery URL: ${discoveryUrl}`); // Initiate OAuth flow accessToken = await performOAuthFlow(httpServerUrl); if (accessToken) { // Create a custom HTTP client that includes the Authorization header const authenticatedHttpClient = await createAuthenticatedHttpClient(httpServerUrl, accessToken); console.error('Connected to HTTP server with OAuth token'); return authenticatedHttpClient; } } } throw error; } } // Perform OAuth flow with browser launch async function performOAuthFlow(serverUrl) { try { console.error('Starting OAuth browser flow...'); // Generate PKCE challenge console.error('Generating PKCE challenge...'); const pkce = await pkceChallenge(); console.error('PKCE challenge generated successfully'); // Get the OAuth base URL from server config (this should point to Supabase) const oauthBaseUrl = process.env.TOOLFLOW_OAUTH_BASE_URL || 'https://toolflow-six.vercel.app'; // Find an available port for the callback server const callbackPort = await findAvailablePort(8080); const callbackUrl = `http://localhost:${callbackPort}/callback`; console.error(`Using callback port: ${callbackPort}`); // Launch browser to OAuth authorization URL with PKCE parameters and resource const resourceUrl = `http://localhost:${HTTP_PORT}/mcp`; // The MCP server resource URL const authUrl = `${oauthBaseUrl}/api/oauth/authorize?response_type=code&client_id=mcp-client&redirect_uri=${encodeURIComponent(callbackUrl)}&code_challenge=${encodeURIComponent(pkce.code_challenge)}&code_challenge_method=S256&resource=${encodeURIComponent(resourceUrl)}`; console.error(`Opening browser to: ${authUrl}`); // Import dynamic module for cross-platform browser opening const { default: open } = await import('open'); await open(authUrl); // Start temporary callback server to receive OAuth code const callbackServer = await startCallbackServer(callbackPort); const code = await waitForCallback(callbackServer); if (code) { // Exchange code for token with PKCE verification console.error('Exchanging authorization code for access token with PKCE...'); const tokenResponse = await fetch(`${oauthBaseUrl}/api/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code: code, redirect_uri: callbackUrl, code_verifier: pkce.code_verifier, // PKCE verification client_id: 'mcp-client' }) }); const tokenData = await tokenResponse.json(); if (!tokenResponse.ok) { console.error('Token exchange failed:', tokenData); throw new Error(`Token exchange failed: ${tokenData.error_description || tokenData.error}`); } if (!tokenData.access_token) { console.error('No access token in response:', tokenData); throw new Error('No access token received'); } console.error('OAuth flow completed successfully'); return tokenData.access_token; } } catch (error) { console.error('OAuth flow failed:', error); } return null; } // Start temporary HTTP server to receive OAuth callback async function startCallbackServer(port = 8080) { const http = await import('http'); return http.createServer((req, res) => { if (req.url?.startsWith('/callback')) { const url = new URL(req.url, `http://localhost:${port}`); const code = url.searchParams.get('code'); if (code) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"> <h2>✅ Authentication Successful</h2> <p>You can now close this window and return to your terminal.</p> </body> </html> `); } else { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<h2>❌ Authentication Failed</h2><p>No authorization code received.</p>'); } } }).listen(port); } // Wait for OAuth callback async function waitForCallback(server) { return new Promise((resolve) => { server.on('request', (req) => { if (req.url?.startsWith('/callback')) { const port = server.address()?.port || 8080; const url = new URL(req.url, `http://localhost:${port}`); const code = url.searchParams.get('code'); server.close(); resolve(code); } }); // Timeout after 5 minutes setTimeout(() => { server.close(); resolve(null); }, 5 * 60 * 1000); }); } // Create stdio-to-HTTP bridge with OAuth support async function createStdioBridge(httpServerUrl) { console.error('Creating stdio-to-HTTP bridge...'); // Use OAuth-aware connection const { httpClient } = await createOAuthAwareConnection(httpServerUrl); console.error('Connected to HTTP server via OAuth-aware transport'); // Create stdio server that proxies to HTTP client const stdioServer = new Server({ name: 'toolflow-mcp', version: '1.0.20', }, { capabilities: { tools: {}, }, }); // Proxy Initialize request stdioServer.setRequestHandler(InitializeRequestSchema, async (request) => { console.error('Proxying Initialize request to HTTP server'); // Initialize is handled locally to establish connection return { protocolVersion: request.params.protocolVersion, capabilities: { tools: {}, logging: {}, }, serverInfo: { name: 'toolflow-mcp', version: '1.0.20', } }; }); // Proxy ListTools request stdioServer.setRequestHandler(ListToolsRequestSchema, async () => { console.error('Proxying ListTools request to HTTP server'); try { const response = await httpClient.request({ method: 'tools/list' }, ListToolsResultSchema); return response; } catch (error) { console.error('Error proxying ListTools:', error); throw error; } }); // Proxy CallTool request stdioServer.setRequestHandler(CallToolRequestSchema, async (request) => { console.error(`Proxying CallTool request for tool: ${request.params.name}`); try { const response = await httpClient.request({ method: 'tools/call', params: { name: request.params.name, arguments: request.params.arguments } }, CallToolResultSchema); return response; } catch (error) { console.error('Error proxying CallTool:', error); throw error; } }); // Create stdio transport const stdioTransport = new StdioServerTransport(); // Connect stdio server to transport await stdioServer.connect(stdioTransport); console.error('Stdio-to-HTTP bridge ready'); // OAuth instructions if (!process.env.TOOLFLOW_API_KEY) { console.error(''); console.error('=== OAuth Authentication ==='); console.error(`When prompted, your browser will open for authentication.`); console.error(`Server running on: ${HTTP_SERVER_URL}`); console.error(''); console.error('OAuth flow uses dynamic callback ports for security.'); console.error('========================='); console.error(''); } return { httpClient, stdioServer, stdioTransport }; } async function main() { console.error('ToolFlow MCP Server starting in proxy mode (stdio-to-HTTP)...'); console.error('Automatically finding available port...'); let httpServer = null; try { // Start HTTP server (this sets HTTP_PORT and HTTP_SERVER_URL) httpServer = await startHttpServer(); console.error(`Using port: ${HTTP_PORT}`); // Create stdio bridge const { httpClient, stdioTransport } = await createStdioBridge(HTTP_SERVER_URL); console.error('ToolFlow MCP Server ready! OAuth authentication is available.'); // Handle graceful shutdown const cleanup = async () => { console.error('Shutting down ToolFlow MCP proxy...'); try { await stdioTransport.close(); httpClient.close(); if (httpServer) { httpServer.kill('SIGTERM'); // Give it time to shut down gracefully await new Promise(resolve => setTimeout(resolve, 1000)); if (!httpServer.killed) { httpServer.kill('SIGKILL'); } } console.error('Proxy shutdown complete'); } catch (error) { console.error('Error during shutdown:', error); } process.exit(0); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); // Handle unexpected HTTP server exit httpServer.on('exit', (code) => { if (code !== 0 && code !== null) { console.error(`HTTP server exited unexpectedly with code ${code}`); process.exit(1); } }); } catch (error) { console.error('Failed to start ToolFlow MCP proxy:', error); if (httpServer) { httpServer.kill('SIGKILL'); } process.exit(1); } } // Run the proxy main().catch(error => { console.error("ToolFlow MCP proxy error:", error); process.exit(1); }); //# sourceMappingURL=index-proxy.js.map