Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
photon-loader.ts•10.7 kB
/** * Photon Loader * * Discovers and loads Photon classes from: * 1. Built-in directory (src/internal-mcps/) * 2. Global user directory (~/.ncp/internal/) * 3. Project-local directory (.ncp/internal/) */ import * as fs from 'fs/promises'; import * as path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import * as crypto from 'crypto'; import { PhotonMCP, DependencyManager } from '@portel/photon-core'; import { PhotonAdapter } from './photon-adapter.js'; import { InternalMCP } from './types.js'; import { logger } from '../utils/logger.js'; import envPaths from 'env-paths'; export class PhotonLoader { private loadedMCPs: Map<string, InternalMCP> = new Map(); private dependencyManager: DependencyManager; constructor() { this.dependencyManager = new DependencyManager(); } /** * Load all Photon classes from multiple directories */ async loadAll(directories: string[]): Promise<InternalMCP[]> { const mcps: InternalMCP[] = []; for (const directory of directories) { try { const dirMCPs = await this.loadFromDirectory(directory); mcps.push(...dirMCPs); } catch (error: any) { logger.warn(`Failed to load MCPs from ${directory}: ${error.message}`); } } return mcps; } /** * Load Photon classes from a directory */ async loadFromDirectory(directory: string): Promise<InternalMCP[]> { const mcps: InternalMCP[] = []; try { // Check if directory exists const stat = await fs.stat(directory); if (!stat.isDirectory()) { return mcps; } // Find all .photon.ts and .photon.js files const files = await this.findMCPFiles(directory); logger.debug(`Found ${files.length} MCP files in ${directory}`); // Load each file for (const filePath of files) { try { const mcp = await this.loadMCPFile(filePath); if (mcp) { mcps.push(mcp); this.loadedMCPs.set(mcp.name, mcp); } } catch (error: any) { logger.error(`Failed to load MCP from ${filePath}: ${error.message}`); } } } catch (error: any) { // Directory doesn't exist or can't be read logger.debug(`Cannot load from ${directory}: ${error.message}`); } return mcps; } /** * Find all .photon.ts and .photon.js files in a directory */ private async findMCPFiles(directory: string): Promise<string[]> { const files: string[] = []; const entries = await fs.readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { // Recursively search subdirectories const subFiles = await this.findMCPFiles(fullPath); files.push(...subFiles); } else if (entry.isFile() && this.isMCPFile(entry.name)) { files.push(fullPath); } } return files; } /** * Check if file is a Photon file */ private isMCPFile(filename: string): boolean { return filename.endsWith('.photon.ts') || filename.endsWith('.photon.js') || filename.endsWith('.photon.ts') || filename.endsWith('.photon.js'); // Backward compat during migration } /** * Load a single MCP file */ private async loadMCPFile(filePath: string): Promise<InternalMCP | null> { try { // Find source .ts file if we're loading from dist let sourceFilePath = filePath; if (filePath.includes('/dist/') && filePath.endsWith('.js')) { const isPhoton = filePath.endsWith('.photon.js'); const ext = isPhoton ? '.photon' : '.micro'; // First try .schema.json in dist (for packaged DXT) // If not found, will fall back to src/*.ts in development sourceFilePath = filePath.replace(`${ext}.js`, `${ext}.ts`); // Check if we should try src/ instead (development mode) const schemaInDist = filePath.replace(`${ext}.js`, `${ext}.schema.json`); try { await fs.access(schemaInDist); // Schema file exists in dist, use dist path sourceFilePath = filePath.replace(`${ext}.js`, `${ext}.ts`); } catch { // No schema in dist, try src/ (development mode) sourceFilePath = filePath .replace('/dist/', '/src/') .replace(`${ext}.js`, `${ext}.ts`); } } // Extract and install dependencies (only if source file exists) let nodeModulesPath: string | null = null; try { await fs.access(sourceFilePath); const dependencies = await this.dependencyManager.extractDependencies(sourceFilePath); if (dependencies.length > 0) { logger.info(`📦 Found ${dependencies.length} dependencies in ${path.basename(filePath)}`); // Get MCP name for cache directory const mcpName = path.basename(filePath, '.photon.js').replace('.photon.ts', ''); // Install dependencies and get node_modules path nodeModulesPath = await this.dependencyManager.ensureDependencies(mcpName, dependencies); } } catch (error: any) { // Source file doesn't exist (production mode) - skip dependency extraction logger.debug(`Skipping dependency extraction for ${path.basename(filePath)} (source not found)`); } // Convert file path to file:// URL for ESM imports const fileUrl = pathToFileURL(filePath).href; // Dynamically import the module let module: any; if (filePath.endsWith('.ts')) { // Compile TypeScript to JavaScript using esbuild logger.debug(`Compiling TypeScript file: ${path.basename(filePath)}`); const cachedJsPath = await this.compileTypeScript(filePath, nodeModulesPath || undefined); const cachedJsUrl = pathToFileURL(cachedJsPath).href; module = await import(cachedJsUrl); } else { // Regular JavaScript import module = await import(fileUrl); } // Find all exported classes (Photon or plain classes) const mcpClasses = this.findMCPClasses(module); if (mcpClasses.length === 0) { logger.warn(`No Photon classes found in ${path.basename(filePath)}`); return null; } if (mcpClasses.length > 1) { logger.warn( `Multiple Photon classes found in ${path.basename(filePath)}. Using first one.` ); } // Use the first Photon class found const MCPClass = mcpClasses[0]; const instance = new MCPClass(); // Call lifecycle hook if present if (instance.onInitialize) { await instance.onInitialize(); } // Create adapter (async initialization) const adapter = await PhotonAdapter.create(MCPClass, instance, sourceFilePath); logger.info(`✅ Loaded Photon: ${adapter.name} (${adapter.tools.length} tools)`); return adapter; } catch (error: any) { logger.error(`Failed to load ${filePath}: ${error.message}`); console.error(`[PhotonLoader] Failed to load ${path.basename(filePath)}: ${error.message}`); console.error(error.stack); return null; } } /** * Compile TypeScript file to JavaScript and cache it */ private async compileTypeScript(tsFilePath: string, nodeModulesPath?: string): Promise<string> { // Generate cache path based on file content hash const tsContent = await fs.readFile(tsFilePath, 'utf-8'); const hash = crypto.createHash('sha256').update(tsContent).digest('hex').slice(0, 16); // IMPORTANT: Store compiled file in same directory as dependencies for module resolution // This allows Node to find npm packages when the compiled code imports them const mcpName = path.basename(tsFilePath, '.photon.ts'); const cacheDir = nodeModulesPath ? path.dirname(nodeModulesPath) // Same dir as node_modules : path.join(envPaths('ncp', { suffix: '' }).cache, 'compiled-mcp'); // Fallback const fileName = path.basename(tsFilePath, '.ts'); const cachedJsPath = path.join(cacheDir, `${fileName}.${hash}.mjs`); // Check if cached version exists try { await fs.access(cachedJsPath); logger.debug(`Using cached compiled version: ${path.basename(cachedJsPath)}`); return cachedJsPath; } catch { // Cache miss - compile it } // Compile TypeScript to JavaScript (no bundling - dependencies resolved at runtime) logger.debug(`Compiling ${path.basename(tsFilePath)} with esbuild...`); const esbuild = await import('esbuild'); const result = await esbuild.transform(tsContent, { loader: 'ts', format: 'esm', target: 'es2022', sourcemap: 'inline' }); // Ensure cache directory exists await fs.mkdir(cacheDir, { recursive: true }); // Write compiled JavaScript to cache await fs.writeFile(cachedJsPath, result.code, 'utf-8'); logger.debug(`Cached compiled JS: ${path.basename(cachedJsPath)}`); return cachedJsPath; } /** * Find all classes in a module * * Accepts ANY class - no base class requirement! * Just like PHP Restler, we use pure convention. */ private findMCPClasses(module: any): Array<any> { const classes: Array<any> = []; for (const exportedItem of Object.values(module)) { if (typeof exportedItem === 'function' && this.isClass(exportedItem)) { // Check if it has async methods (indication it's a Photon) if (this.hasAsyncMethods(exportedItem)) { classes.push(exportedItem); } } } return classes; } /** * Check if a function is a class constructor */ private isClass(fn: any): boolean { return typeof fn === 'function' && /^\s*class\s+/.test(fn.toString()); } /** * Check if a class has async methods */ private hasAsyncMethods(ClassConstructor: any): boolean { const prototype = ClassConstructor.prototype; for (const key of Object.getOwnPropertyNames(prototype)) { if (key === 'constructor') continue; const descriptor = Object.getOwnPropertyDescriptor(prototype, key); if (descriptor && typeof descriptor.value === 'function') { // Check if it's an async function const fn = descriptor.value; if (fn.constructor.name === 'AsyncFunction') { return true; } } } return false; } /** * Get all loaded MCPs */ getLoadedMCPs(): InternalMCP[] { return Array.from(this.loadedMCPs.values()); } /** * Get a loaded MCP by name */ getMCP(name: string): InternalMCP | undefined { return this.loadedMCPs.get(name); } }

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/portel-dev/ncp'

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