@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
JavaScript
/**
* 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