Skip to main content
Glama
server.ts12.4 kB
import { OAuth2Client } from 'google-auth-library'; import { TokenManager } from './tokenManager.js'; import http from 'http'; import { URL } from 'url'; import open from 'open'; import { loadCredentials } from './client.js'; import { getAccountMode } from './utils.js'; import { renderAuthSuccess, renderAuthError, renderAuthLanding, loadWebFile } from '../web/templates.js'; export interface StartForMcpToolResult { success: boolean; authUrl?: string; callbackUrl?: string; error?: string; } export class AuthServer { private baseOAuth2Client: OAuth2Client; // Used by TokenManager for validation/refresh private flowOAuth2Client: OAuth2Client | null = null; // Used specifically for the auth code flow private server: http.Server | null = null; private tokenManager: TokenManager; private portRange: { start: number; end: number }; private activeConnections: Set<import('net').Socket> = new Set(); // Track active socket connections public authCompletedSuccessfully = false; // Flag for standalone script private mcpToolTimeout: ReturnType<typeof setTimeout> | null = null; // Timeout for MCP tool auth flow private autoShutdownOnSuccess = false; // Whether to auto-shutdown after successful auth constructor(oauth2Client: OAuth2Client) { this.baseOAuth2Client = oauth2Client; this.tokenManager = new TokenManager(oauth2Client); this.portRange = { start: 3500, end: 3505 }; } private createServer(): http.Server { const server = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host}`); if (url.pathname === '/styles.css') { // Serve shared CSS const css = await loadWebFile('styles.css'); res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' }); res.end(css); } else if (url.pathname === '/') { // Root route - show auth link const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client; const scopes = ['https://www.googleapis.com/auth/calendar']; const authUrl = clientForUrl.generateAuthUrl({ access_type: 'offline', scope: scopes, prompt: 'consent' }); const accountMode = getAccountMode(); const landingHtml = await renderAuthLanding({ accountId: accountMode, authUrl: authUrl }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(landingHtml); } else if (url.pathname === '/oauth2callback') { // OAuth callback route const code = url.searchParams.get('code'); if (!code) { const errorHtml = await renderAuthError({ errorMessage: 'Authorization code missing' }); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorHtml); return; } if (!this.flowOAuth2Client) { const errorHtml = await renderAuthError({ errorMessage: 'Authentication flow not properly initiated.' }); res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorHtml); return; } try { const { tokens } = await this.flowOAuth2Client.getToken(code); await this.tokenManager.saveTokens(tokens); this.authCompletedSuccessfully = true; const tokenPath = this.tokenManager.getTokenPath(); const accountMode = this.tokenManager.getAccountMode(); // Auto-shutdown after successful auth if triggered by MCP tool if (this.autoShutdownOnSuccess) { // Clear the timeout since auth succeeded if (this.mcpToolTimeout) { clearTimeout(this.mcpToolTimeout); this.mcpToolTimeout = null; } // Give the browser time to render success page, then shutdown setTimeout(() => { this.stop().catch(() => {}); }, 2000); } const successHtml = await renderAuthSuccess({ accountId: accountMode, tokenPath: tokenPath }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(successHtml); } catch (error: unknown) { this.authCompletedSuccessfully = false; const message = error instanceof Error ? error.message : 'Unknown error'; process.stderr.write(`✗ Token save failed: ${message}\n`); const errorHtml = await renderAuthError({ errorMessage: message }); res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorHtml); } } else { // 404 for other routes res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } }); // Track connections at server level server.on('connection', (socket) => { this.activeConnections.add(socket); socket.on('close', () => { this.activeConnections.delete(socket); }); }); return server; } async start(openBrowser = true): Promise<boolean> { // Add timeout wrapper to prevent hanging return Promise.race([ this.startWithTimeout(openBrowser), new Promise<boolean>((_, reject) => { setTimeout(() => reject(new Error('Auth server start timed out after 10 seconds')), 10000); }) ]).catch(() => false); // Return false on timeout instead of throwing } private async startWithTimeout(openBrowser = true): Promise<boolean> { if (await this.tokenManager.validateTokens()) { this.authCompletedSuccessfully = true; return true; } // Try to start the server and get the port const port = await this.startServerOnAvailablePort(); if (port === null) { process.stderr.write(`Could not start auth server on available port. Please check port availability (${this.portRange.start}-${this.portRange.end}) and try again.\n`); this.authCompletedSuccessfully = false; return false; } // Successfully started server on `port`. Now create the flow-specific OAuth client. try { const { client_id, client_secret } = await loadCredentials(); this.flowOAuth2Client = new OAuth2Client( client_id, client_secret, `http://localhost:${port}/oauth2callback` ); } catch (error) { // Could not load credentials, cannot proceed with auth flow this.authCompletedSuccessfully = false; await this.stop(); // Stop the server we just started return false; } // Generate Auth URL using the newly created flow client const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/calendar'], prompt: 'consent' }); // Always show the URL in console for easy access process.stderr.write(`\n🔗 Authentication URL: ${authorizeUrl}\n\n`); process.stderr.write(`Or visit: http://localhost:${port}\n\n`); if (openBrowser) { try { await open(authorizeUrl); process.stderr.write(`Browser opened automatically. If it didn't open, use the URL above.\n`); } catch (error) { process.stderr.write(`Could not open browser automatically. Please use the URL above.\n`); } } else { process.stderr.write(`Please visit the URL above to complete authentication.\n`); } return true; // Auth flow initiated } private async startServerOnAvailablePort(): Promise<number | null> { for (let port = this.portRange.start; port <= this.portRange.end; port++) { try { await new Promise<void>((resolve, reject) => { const testServer = this.createServer(); testServer.listen(port, () => { this.server = testServer; // Assign to class property *only* if successful resolve(); }); testServer.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { // Port is in use, close the test server and reject testServer.close(() => reject(err)); } else { // Other error, reject reject(err); } }); }); return port; // Port successfully bound } catch (error: unknown) { // Check if it's EADDRINUSE, otherwise rethrow or handle if (!(error instanceof Error && 'code' in error && error.code === 'EADDRINUSE')) { // An unexpected error occurred during server start return null; } // EADDRINUSE occurred, loop continues } } return null; // No port found } public getRunningPort(): number | null { if (this.server) { const address = this.server.address(); if (typeof address === 'object' && address !== null) { return address.port; } } return null; } async stop(): Promise<void> { // Clear any pending MCP tool timeout if (this.mcpToolTimeout) { clearTimeout(this.mcpToolTimeout); this.mcpToolTimeout = null; } this.autoShutdownOnSuccess = false; return new Promise((resolve, reject) => { if (this.server) { // Force close all active connections for (const connection of this.activeConnections) { connection.destroy(); } this.activeConnections.clear(); // Add a timeout to force close if server doesn't close gracefully const timeout = setTimeout(() => { process.stderr.write('Server close timeout, forcing exit...\n'); this.server = null; resolve(); }, 2000); // 2 second timeout this.server.close((err) => { clearTimeout(timeout); if (err) { reject(err); } else { this.server = null; resolve(); } }); } else { resolve(); } }); } /** * Start the auth server for use by an MCP tool. * * Unlike the regular start() method: * - Does not open the browser automatically * - Returns the auth URL for the MCP tool to return to the user * - Auto-shutdowns after successful auth or timeout (5 minutes) * - Does not validate existing tokens (allows adding new accounts) * * @param accountId - The account ID to authenticate * @returns Result with auth URL on success, or error on failure */ async startForMcpTool(accountId: string): Promise<StartForMcpToolResult> { // If server is already running, stop it first if (this.server) { await this.stop(); } // Set the account mode this.tokenManager.setAccountMode(accountId); // Try to start the server and get the port const port = await this.startServerOnAvailablePort(); if (port === null) { return { success: false, error: `Could not start auth server. Ports ${this.portRange.start}-${this.portRange.end} may be in use.` }; } // Create the flow-specific OAuth client try { const { client_id, client_secret } = await loadCredentials(); this.flowOAuth2Client = new OAuth2Client( client_id, client_secret, `http://localhost:${port}/oauth2callback` ); } catch (error) { await this.stop(); return { success: false, error: `Failed to load OAuth credentials: ${error instanceof Error ? error.message : 'Unknown error'}` }; } // Generate Auth URL const authUrl = this.flowOAuth2Client.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/calendar'], prompt: 'consent' }); // Enable auto-shutdown on success this.autoShutdownOnSuccess = true; this.authCompletedSuccessfully = false; // Set timeout to auto-shutdown if auth not completed (5 minutes) this.mcpToolTimeout = setTimeout(async () => { if (!this.authCompletedSuccessfully) { process.stderr.write(`Auth timeout for account "${accountId}" - shutting down auth server\n`); await this.stop(); } }, 5 * 60 * 1000); return { success: true, authUrl, callbackUrl: `http://localhost:${port}/oauth2callback` }; } }

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/nspady/google-calendar-mcp'

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