Skip to main content
Glama
module-loader.ts11.7 kB
import type { LoadedServer, ServerProcess, ServerType } from '../types/server.js' import type { ServerConfig } from '../types/config.js' import type { AuthHeaders } from '../types/auth.js' import { Logger } from '../utils/logger.js' export interface ModuleLoaderOptions { healthEndpoint?: string // path appended to endpoint, defaults to '/health' capabilitiesEndpoint?: string // path appended to endpoint, defaults to '/capabilities' defaultHostname?: string // defaults to 'localhost' } export interface ModuleLoader { loadServers(configs: ServerConfig[], clientToken?: string): Promise<Map<string, LoadedServer>> load(config: ServerConfig, clientToken?: string): Promise<LoadedServer> unload(id: string): Promise<void> performHealthCheck(server: LoadedServer, clientToken?: string): Promise<boolean> restartServer(serverId: string): Promise<void> } /** * DefaultModuleLoader implements multi-source loading with cross-platform process placeholders. * It avoids Node-specific APIs so it can compile for both Node and Workers builds. * Actual spawning should be implemented by a platform-specific adapter in later phases. */ export class DefaultModuleLoader implements ModuleLoader { private servers = new Map<string, LoadedServer>() private options: Required<ModuleLoaderOptions> constructor(options?: ModuleLoaderOptions) { this.options = { healthEndpoint: options?.healthEndpoint ?? '/health', capabilitiesEndpoint: options?.capabilitiesEndpoint ?? '/capabilities', defaultHostname: options?.defaultHostname ?? 'localhost', } } async loadServers(configs: ServerConfig[], clientToken?: string): Promise<Map<string, LoadedServer>> { const results = await Promise.all( configs.map(async (cfg) => { try { const server = await this.load(cfg, clientToken) return [cfg.id, server] as const } catch (err) { Logger.error(`Failed to load server ${cfg.id}`, err) const server: LoadedServer = { id: cfg.id, type: 'unknown', endpoint: 'unknown', config: cfg, status: 'error', lastHealthCheck: Date.now(), } return [cfg.id, server] as const } }) ) for (const [id, s] of results) this.servers.set(id, s) return new Map(this.servers) } async load(config: ServerConfig, clientToken?: string): Promise<LoadedServer> { const type = this.detectServerType(config) const base: LoadedServer = { id: config.id, type, endpoint: this.deriveEndpoint(config), config, status: 'starting', lastHealthCheck: 0, } let loaded: LoadedServer switch (config.type) { case 'git': loaded = await this.loadFromGit(config, base) break case 'npm': loaded = await this.loadFromNpm(config, base) break case 'pypi': loaded = await this.loadFromPypi(config, base) break case 'docker': loaded = await this.loadFromDocker(config, base) break case 'local': loaded = await this.loadFromLocal(config, base) break default: loaded = base } // Immediate health check to set running/error try { const ok = await this.performHealthCheck(loaded, clientToken) loaded.status = ok ? 'running' : 'error' } catch (err) { Logger.warn(`Health check failed for ${config.id}`, err) loaded.status = 'error' } this.servers.set(loaded.id, loaded) return loaded } async unload(id: string): Promise<void> { const server = this.servers.get(id) if (!server) return try { await server.process?.stop() } catch (err) { Logger.warn(`Error stopping server ${id}`, err) } finally { this.servers.delete(id) Logger.logServerEvent('unloaded', id) } } async restartServer(serverId: string): Promise<void> { const server = this.servers.get(serverId) if (!server) throw new Error(`Server not found: ${serverId}`) Logger.logServerEvent('restarting', serverId) try { await server.process?.stop() } catch (err) { Logger.warn(`Error stopping server ${serverId} during restart`, err) } // Re-load using the same config const reloaded = await this.load(server.config) this.servers.set(serverId, reloaded) } async performHealthCheck(server: LoadedServer, clientToken?: string): Promise<boolean> { if (!server.endpoint || server.endpoint === 'unknown') { server.lastHealthCheck = Date.now() server.status = 'error' return false } const url = new URL(this.options.healthEndpoint, this.ensureTrailingSlash(server.endpoint)).toString() const headers: AuthHeaders = {} // In Phase 3, auth integration is handled at higher layers; here we only accept a caller-provided token. if (clientToken) headers['Authorization'] = `Bearer ${clientToken}` try { const res = await fetch(url, { headers }) server.lastHealthCheck = Date.now() if (!res.ok) return false const ct = res.headers.get('content-type') || '' if (ct.includes('application/json')) { const json = (await res.json()) as any return Boolean(json?.ok ?? true) } return true } catch (err) { server.lastHealthCheck = Date.now() Logger.warn(`Health check request failed for ${server.id}`, err) return false } } // --- Multi-source loading stubs (network/process performed outside this module in later phases) --- private async loadFromGit(config: ServerConfig, base: LoadedServer): Promise<LoadedServer> { // In a full implementation, we'd clone and install. Here we assume it's pre-built and start it. Logger.logServerEvent('loadFromGit', config.id, { url: config.url, branch: config.branch }) return this.startRuntime(config, base) } private async loadFromNpm(config: ServerConfig, base: LoadedServer): Promise<LoadedServer> { Logger.logServerEvent('loadFromNpm', config.id, { pkg: config.package, version: config.version }) return this.startRuntime(config, base) } private async loadFromPypi(config: ServerConfig, base: LoadedServer): Promise<LoadedServer> { Logger.logServerEvent('loadFromPypi', config.id, { pkg: config.package, version: config.version }) return this.startRuntime(config, base) } private async loadFromDocker(config: ServerConfig, base: LoadedServer): Promise<LoadedServer> { Logger.logServerEvent('loadFromDocker', config.id, { image: config.package, tag: config.version }) // Docker orchestration would run the container exposing a port; we only resolve endpoint here return { ...base, status: 'starting' } } private async loadFromLocal(config: ServerConfig, base: LoadedServer): Promise<LoadedServer> { Logger.logServerEvent('loadFromLocal', config.id, { path: config.url }) Logger.info('Loading local server', { config }) const result = await this.startRuntime(config, base) Logger.info('Loaded local server', { result }) return result } // --- Runtime orchestration and type detection --- private detectServerType(config: ServerConfig): ServerType { // Heuristics: look at package name/url/args for hints const name = (config.package ?? config.url ?? '').toLowerCase() Logger.info('Detecting server type', { name, config }) // For file URLs, we should detect as stdio if (config.url && config.url.startsWith('file://')) { Logger.info('Assuming stdio server for file URL', { url: config.url }) return 'stdio' } // For URLs, we can't determine the type from the URL itself // Let's assume it's a node server for HTTP URLs if (config.url && /^https?:\/\//i.test(config.url)) { Logger.info('Assuming node server for HTTP URL', { url: config.url }) return 'node' } if (name.endsWith('.py') || /py|pypi|python/.test(name)) return 'python' if (/ts|typescript/.test(name)) return 'typescript' if (/node|js|npm/.test(name)) return 'node' return 'unknown' } private deriveEndpoint(config: ServerConfig): string { const port = config.config.port if (port) return `http://${this.options.defaultHostname}:${port}` // If URL looks like http(s):// use as-is const url = config.url ?? '' Logger.info('Deriving endpoint', { url, config }) if (/^https?:\/\//i.test(url)) return url // For file URLs (STDIO servers), we can't derive an HTTP endpoint if (url.startsWith('file://')) return 'unknown' return 'unknown' } private async startRuntime(config: ServerConfig, base: LoadedServer): Promise<LoadedServer> { const type = this.detectServerType(config) Logger.info('Detected server type', { type, config }) let proc: ServerProcess | undefined try { // For remote servers (HTTP URLs), we don't need to start a process if (config.url && /^https?:\/\//i.test(config.url)) { Logger.info('Remote server, no process to start', { url: config.url }) proc = undefined } else if (type === 'stdio') { // For STDIO servers, start as a child process Logger.info('Starting STDIO server as child process', { url: config.url }) const { StdioManager } = await import('./stdio-manager.js') const stdioManager = new StdioManager() const filePath = config.url!.replace('file://', '') proc = await stdioManager.startServer(config.id, filePath, config.config.environment) } else if (type === 'python') { proc = await this.startPythonServer(config) } else if (type === 'typescript' || type === 'node') { proc = await this.startTypeScriptServer(config) } else { // Unknown: assume externally managed endpoint proc = undefined } } catch (err) { Logger.error(`Failed to start runtime for ${config.id}`, err) return { ...base, status: 'error' } } Logger.info('Started runtime', { proc, base }) return { ...base, process: proc, status: 'starting' } } // Cross-platform placeholders. Real implementation should manage child processes per-OS. private async startPythonServer(_config: ServerConfig): Promise<ServerProcess> { // Placeholder: assume an external process is started via orchestrator. Provide a no-op stop. return { stop: async () => void 0 } } private async startTypeScriptServer(config: ServerConfig): Promise<ServerProcess> { // Check if this is a file URL that should be started as a child process if (config.url && config.url.startsWith('file://')) { Logger.info('Starting TypeScript server as child process', { url: config.url }) // Import child_process dynamically const { spawn } = await import('node:child_process') // Convert file:// URL to path const filePath = config.url.replace('file://', '') // Spawn the process const proc = spawn('node', [filePath], { stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr env: { ...process.env, ...config.config.environment } }) return { pid: proc.pid, stop: async () => { return new Promise((resolve) => { proc.kill() setTimeout(() => resolve(), 1000) // Wait 1 second for graceful shutdown }) } } } // Placeholder: assume an external process is started via orchestrator. Provide a no-op stop. return { stop: async () => void 0 } } private ensureTrailingSlash(endpoint: string): string { if (!endpoint.endsWith('/')) return `${endpoint}/` return endpoint } }

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/Jakedismo/master-mcp-server'

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