Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
photon-marketplace-client.ts28.4 kB
/** * Photon Marketplace Client - Manage multiple Photon marketplaces * * Integrates with Photon's marketplace system to query and install Photons * from the official portel-dev/photons repository and user-defined marketplaces */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { existsSync } from 'fs'; import * as crypto from 'crypto'; import { logger } from '../utils/logger.js'; import { getNcpBaseDirectory } from '../utils/ncp-paths.js'; export type MarketplaceSourceType = 'github' | 'git-ssh' | 'url' | 'local'; export interface Marketplace { name: string; repo: string; // For GitHub sources url: string; // Base URL for fetching sourceType: MarketplaceSourceType; source: string; // Original input (for display) enabled: boolean; lastUpdated?: string; } export interface MarketplaceConfig { marketplaces: Marketplace[]; } /** * Photon metadata from photons.json manifest */ export interface PhotonMetadata { name: string; version: string; description: string; author?: string; homepage?: string; repository?: string; license?: string; tags?: string[]; category?: string; source: string; hash?: string; // SHA-256 hash of the file content tools?: string[]; } /** * Local installation metadata for tracking Photon origins */ export interface PhotonInstallMetadata { marketplace: string; marketplaceRepo: string; version: string; originalHash: string; installedAt: string; lastChecked?: string; } /** * Local metadata file structure */ export interface LocalMetadata { photons: Record<string, PhotonInstallMetadata>; } /** * Marketplace manifest (.marketplace/photons.json) */ export interface MarketplaceManifest { name: string; version?: string; description?: string; owner?: { name: string; email?: string; url?: string; }; photons: PhotonMetadata[]; } // Helper functions to get directories (respects getNcpBaseDirectory for flexibility) function getConfigDir(): string { return getNcpBaseDirectory(); } function getConfigFile(): string { return path.join(getConfigDir(), 'photon-marketplaces.json'); } function getCacheDir(): string { return path.join(getConfigDir(), '.cache', 'photon-marketplaces'); } function getMetadataFile(): string { return path.join(getConfigDir(), '.photon-metadata.json'); } // Cache is considered stale after 24 hours const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MARKETPLACE: Marketplace = { name: 'photons', repo: 'portel-dev/photons', url: 'https://raw.githubusercontent.com/portel-dev/photons/main', sourceType: 'github', source: 'portel-dev/photons', enabled: true, }; /** * Calculate SHA-256 hash of file content */ export async function calculateFileHash(filePath: string): Promise<string> { const content = await fs.readFile(filePath, 'utf-8'); const hash = crypto.createHash('sha256').update(content).digest('hex'); return `sha256:${hash}`; } /** * Calculate SHA-256 hash of string content */ export function calculateHash(content: string): string { const hash = crypto.createHash('sha256').update(content).digest('hex'); return `sha256:${hash}`; } /** * Read local installation metadata */ export async function readLocalMetadata(): Promise<LocalMetadata> { try { if (existsSync(getMetadataFile())) { const data = await fs.readFile(getMetadataFile(), 'utf-8'); return JSON.parse(data); } } catch { // File doesn't exist or is invalid } return { photons: {} }; } /** * Write local installation metadata */ export async function writeLocalMetadata(metadata: LocalMetadata): Promise<void> { await fs.mkdir(getConfigDir(), { recursive: true }); await fs.writeFile(getMetadataFile(), JSON.stringify(metadata, null, 2), 'utf-8'); } export class PhotonMarketplaceClient { private config: MarketplaceConfig = { marketplaces: [] }; async initialize() { await fs.mkdir(getConfigDir(), { recursive: true }); await fs.mkdir(getCacheDir(), { recursive: true }); if (existsSync(getConfigFile())) { const data = await fs.readFile(getConfigFile(), 'utf-8'); this.config = JSON.parse(data); } else { // Initialize with default marketplace this.config = { marketplaces: [DEFAULT_MARKETPLACE], }; await this.save(); } } async save() { await fs.writeFile(getConfigFile(), JSON.stringify(this.config, null, 2), 'utf-8'); } /** * Get all marketplaces */ getAll(): Marketplace[] { return this.config.marketplaces; } /** * Get enabled marketplaces */ getEnabled(): Marketplace[] { return this.config.marketplaces.filter((m) => m.enabled); } /** * Get marketplace by name */ get(name: string): Marketplace | undefined { return this.config.marketplaces.find((m) => m.name === name); } /** * Parse marketplace source into structured info * Supports: * 1. GitHub shorthand: username/repo * 2. GitHub HTTPS: https://github.com/username/repo[.git] * 3. GitHub SSH: git@github.com:username/repo.git * 4. Direct URL: https://example.com/photons.json * 5. Local path: ./path/to/marketplace or /absolute/path */ private parseMarketplaceSource(input: string): Omit<Marketplace, 'enabled' | 'lastUpdated'> | null { // Pattern 1: username/repo (GitHub shorthand) const shorthandMatch = input.match(/^([a-zA-Z0-9-]+)\/([a-zA-Z0-9-_.]+)$/); if (shorthandMatch) { const [, username, repo] = shorthandMatch; return { name: repo, repo: input, url: `https://raw.githubusercontent.com/${username}/${repo}/main`, sourceType: 'github', source: input, }; } // Pattern 2: https://github.com/username/repo[.git] (GitHub HTTPS) const httpsMatch = input.match(/^https?:\/\/github\.com\/([a-zA-Z0-9-]+)\/([a-zA-Z0-9-_.]+?)(\.git)?$/); if (httpsMatch) { const [, username, repo] = httpsMatch; return { name: repo, repo: `${username}/${repo}`, url: `https://raw.githubusercontent.com/${username}/${repo}/main`, sourceType: 'github', source: input, }; } // Pattern 3: git@github.com:username/repo.git (GitHub SSH) const sshMatch = input.match(/^git@github\.com:([a-zA-Z0-9-]+)\/([a-zA-Z0-9-_.]+?)(\.git)?$/); if (sshMatch) { const [, username, repo] = sshMatch; const repoName = repo.replace(/\.git$/, ''); return { name: repoName, repo: `${username}/${repoName}`, url: `https://raw.githubusercontent.com/${username}/${repoName}/main`, sourceType: 'git-ssh', source: input, }; } // Pattern 4: https://example.com/photons.json (Direct URL) if (input.startsWith('http://') || input.startsWith('https://')) { // Extract name from URL const urlObj = new URL(input); const pathParts = urlObj.pathname.split('/'); const fileName = pathParts[pathParts.length - 1]; const name = fileName.replace(/\.(json|ts)$/, '') || urlObj.hostname; // Base URL is the directory containing the photons.json const baseUrl = input.replace(/\/[^/]*$/, ''); return { name, repo: '', // Not a repo url: baseUrl, sourceType: 'url', source: input, }; } // Pattern 5: Local filesystem (Unix and Windows paths) // Unix: ./path, ../path, /absolute, ~/path // Windows: C:\path, D:\Users\..., etc. const isLocalPath = input.startsWith('./') || input.startsWith('../') || input.startsWith('/') || input.startsWith('~') || /^[A-Za-z]:[\\/]/.test(input); // Windows drive letter (C:\, D:\, etc.) if (isLocalPath) { // Resolve to absolute path (handles ~ expansion) const absolutePath = path.resolve(input.replace(/^~/, os.homedir())); const name = path.basename(absolutePath); // Normalize path separators for file:// URL // On Windows, path.resolve returns backslashes, but file:// needs forward slashes const normalizedPath = absolutePath.replace(/\\/g, '/'); return { name, repo: '', // Not a repo url: `file://${normalizedPath}`, sourceType: 'local', source: input, }; } return null; } /** * Get next available name with numeric suffix if name already exists * e.g., if 'photon-mcps' exists, returns 'photon-mcps-2' * if 'photon-mcps' and 'photon-mcps-2' exist, returns 'photon-mcps-3' */ private getUniqueName(baseName: string): string { // If base name doesn't exist, use it as-is if (!this.get(baseName)) { return baseName; } // Find next available number let suffix = 2; while (this.get(`${baseName}-${suffix}`)) { suffix++; } return `${baseName}-${suffix}`; } /** * Check if a marketplace with the same source already exists */ private findBySource(source: string): Marketplace | undefined { return this.config.marketplaces.find((m) => m.source === source); } /** * Add a new marketplace * Supports: * - GitHub: username/repo, https://github.com/username/repo, git@github.com:username/repo.git * - Direct URL: https://example.com/photons.json * - Local path: ./path/to/marketplace, /absolute/path * * If a marketplace with the same name already exists, automatically appends a numeric suffix (-2, -3, etc.) * If the exact same source already exists, returns the existing marketplace without creating a duplicate. * * @returns Object with marketplace info and 'added' flag (false if already existed) */ async add(source: string): Promise<{ marketplace: Omit<Marketplace, 'enabled' | 'lastUpdated'>; added: boolean }> { const parsed = this.parseMarketplaceSource(source); if (!parsed) { throw new Error( `Invalid marketplace source format. Supported formats: - GitHub: username/repo - GitHub HTTPS: https://github.com/username/repo - GitHub SSH: git@github.com:username/repo.git - Direct URL: https://example.com/photons.json - Local path: ./path/to/marketplace or /absolute/path` ); } // Check if this exact source is already added const existing = this.findBySource(parsed.source); if (existing) { return { marketplace: { name: existing.name, repo: existing.repo, url: existing.url, sourceType: existing.sourceType, source: existing.source, }, added: false, }; } // Get unique name (adds numeric suffix if name already exists) const uniqueName = this.getUniqueName(parsed.name); const finalParsed = { ...parsed, name: uniqueName }; const marketplace: Marketplace = { ...finalParsed, enabled: true, lastUpdated: new Date().toISOString(), }; this.config.marketplaces.push(marketplace); await this.save(); return { marketplace: finalParsed, added: true, }; } /** * Remove a marketplace */ async remove(name: string): Promise<boolean> { const index = this.config.marketplaces.findIndex((m) => m.name === name); if (index === -1) { return false; } // Prevent removing the default marketplace if (this.config.marketplaces[index].url === DEFAULT_MARKETPLACE.url) { throw new Error('Cannot remove the default photons marketplace'); } this.config.marketplaces.splice(index, 1); await this.save(); return true; } /** * Enable/disable a marketplace */ async setEnabled(name: string, enabled: boolean): Promise<boolean> { const marketplace = this.get(name); if (!marketplace) { return false; } marketplace.enabled = enabled; await this.save(); return true; } /** * Get cache file path for marketplace */ private getCacheFile(marketplaceName: string): string { return path.join(getCacheDir(), `${marketplaceName}.json`); } /** * Fetch photons.json manifest from various sources */ async fetchManifest(marketplace: Marketplace): Promise<MarketplaceManifest | null> { try { if (marketplace.sourceType === 'local') { // Local filesystem const localPath = marketplace.url.replace('file://', ''); const manifestPath = path.join(localPath, '.marketplace', 'photons.json'); if (existsSync(manifestPath)) { const data = await fs.readFile(manifestPath, 'utf-8'); return JSON.parse(data) as MarketplaceManifest; } } else if (marketplace.sourceType === 'url') { // Direct URL - the source already points to photons.json const response = await fetch(marketplace.source); if (response.ok) { return await response.json() as MarketplaceManifest; } } else { // GitHub sources (github, git-ssh) const manifestUrl = `${marketplace.url}/.marketplace/photons.json`; const response = await fetch(manifestUrl); if (response.ok) { return await response.json() as MarketplaceManifest; } } } catch (error: any) { // Marketplace doesn't have a manifest or fetch failed logger.debug(`Failed to fetch manifest from ${marketplace.name}: ${error.message}`); } return null; } /** * Update marketplace cache (fetch and save photons.json manifest) */ async updateMarketplaceCache(name: string): Promise<boolean> { const marketplace = this.get(name); if (!marketplace) { return false; } const manifest = await this.fetchManifest(marketplace); if (manifest) { // Save to cache const cacheFile = this.getCacheFile(name); await fs.writeFile(cacheFile, JSON.stringify(manifest, null, 2), 'utf-8'); // Update lastUpdated timestamp marketplace.lastUpdated = new Date().toISOString(); await this.save(); return true; } return false; } /** * Update all enabled marketplace caches */ async updateAllCaches(): Promise<Map<string, boolean>> { const results = new Map<string, boolean>(); const enabled = this.getEnabled(); for (const marketplace of enabled) { const success = await this.updateMarketplaceCache(marketplace.name); results.set(marketplace.name, success); } return results; } /** * Get cached marketplace manifest */ async getCachedManifest(marketplaceName: string): Promise<MarketplaceManifest | null> { try { const cacheFile = this.getCacheFile(marketplaceName); if (existsSync(cacheFile)) { const data = await fs.readFile(cacheFile, 'utf-8'); return JSON.parse(data); } } catch { // Cache doesn't exist or is invalid } return null; } /** * Get Photon metadata from cached manifest */ async getPhotonMetadata(photonName: string): Promise<{ metadata: PhotonMetadata; marketplace: Marketplace } | null> { const enabled = this.getEnabled(); for (const marketplace of enabled) { const manifest = await this.getCachedManifest(marketplace.name); if (manifest) { const photon = manifest.photons.find((p) => p.name === photonName); if (photon) { return { metadata: photon, marketplace }; } } } return null; } /** * Get all Photons with metadata from all enabled marketplaces */ async getAllPhotons(): Promise<Map<string, { metadata: PhotonMetadata; marketplace: Marketplace }>> { const photons = new Map<string, { metadata: PhotonMetadata; marketplace: Marketplace }>(); const enabled = this.getEnabled(); for (const marketplace of enabled) { const manifest = await this.getCachedManifest(marketplace.name); if (manifest) { for (const photon of manifest.photons) { // First marketplace wins if Photon exists in multiple if (!photons.has(photon.name)) { photons.set(photon.name, { metadata: photon, marketplace }); } } } } return photons; } /** * Get count of available Photons per marketplace */ async getMarketplaceCounts(): Promise<Map<string, number>> { const counts = new Map<string, number>(); const all = this.getAll(); for (const marketplace of all) { const manifest = await this.getCachedManifest(marketplace.name); counts.set(marketplace.name, manifest?.photons.length || 0); } return counts; } /** * Check if marketplace cache is stale */ private isCacheStale(marketplace: Marketplace): boolean { if (!marketplace.lastUpdated) { return true; } const lastUpdate = new Date(marketplace.lastUpdated).getTime(); const now = Date.now(); return (now - lastUpdate) > CACHE_TTL_MS; } /** * Auto-update stale caches * Returns true if any updates were performed */ async autoUpdateStaleCaches(): Promise<boolean> { const enabled = this.getEnabled(); let updated = false; for (const marketplace of enabled) { if (this.isCacheStale(marketplace)) { const success = await this.updateMarketplaceCache(marketplace.name); if (success) { updated = true; } } } return updated; } /** * Try to fetch MCP from all enabled marketplaces * Returns content, marketplace info, and metadata (version, hash) */ async fetchMCP(mcpName: string): Promise<{ content: string; marketplace: Marketplace; metadata?: PhotonMetadata } | null> { const enabled = this.getEnabled(); for (const marketplace of enabled) { try { let content: string | null = null; if (marketplace.sourceType === 'local') { // Local filesystem const localPath = marketplace.url.replace('file://', ''); const mcpPath = path.join(localPath, `${mcpName}.photon.ts`); if (existsSync(mcpPath)) { content = await fs.readFile(mcpPath, 'utf-8'); } } else { // Remote fetch (GitHub, URL) const url = `${marketplace.url}/${mcpName}.photon.ts`; const response = await fetch(url); if (response.ok) { content = await response.text(); } } if (content) { // Try to fetch metadata from manifest const manifest = await this.getCachedManifest(marketplace.name); const metadata = manifest?.photons.find(p => p.name === mcpName); return { content, marketplace, metadata }; } } catch { // Try next marketplace } } return null; } /** * Fetch version from all enabled marketplaces */ async fetchVersion(mcpName: string): Promise<{ version: string; marketplace: Marketplace } | null> { const enabled = this.getEnabled(); for (const marketplace of enabled) { try { let content: string | null = null; if (marketplace.sourceType === 'local') { // Local filesystem const localPath = marketplace.url.replace('file://', ''); const mcpPath = path.join(localPath, `${mcpName}.photon.ts`); if (existsSync(mcpPath)) { content = await fs.readFile(mcpPath, 'utf-8'); } } else { // Remote fetch (GitHub, URL) const url = `${marketplace.url}/${mcpName}.photon.ts`; const response = await fetch(url); if (response.ok) { content = await response.text(); } } if (content) { const versionMatch = content.match(/@version\s+(\d+\.\d+\.\d+)/); if (versionMatch) { return { version: versionMatch[1], marketplace }; } } } catch { // Try next marketplace } } return null; } /** * Search for Photon in all marketplaces * Searches in name, description, tags, and author fields */ async search(query: string): Promise<Map<string, { metadata?: PhotonMetadata; marketplace: Marketplace }[]>> { const results = new Map<string, { metadata?: PhotonMetadata; marketplace: Marketplace }[]>(); const enabled = this.getEnabled(); const lowerQuery = query.toLowerCase(); for (const marketplace of enabled) { // First, try to search in cached manifest const manifest = await this.getCachedManifest(marketplace.name); if (manifest) { // Search in manifest metadata for (const photon of manifest.photons) { const nameMatch = photon.name.toLowerCase().includes(lowerQuery); const descMatch = photon.description?.toLowerCase().includes(lowerQuery); const tagMatch = photon.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)); const authorMatch = photon.author?.toLowerCase().includes(lowerQuery); if (nameMatch || descMatch || tagMatch || authorMatch) { const existing = results.get(photon.name) || []; existing.push({ metadata: photon, marketplace }); results.set(photon.name, existing); } } } else { // Fallback: check if exact filename exists (for marketplaces without manifest) try { const url = `${marketplace.url}/${query}.photon.ts`; const response = await fetch(url, { method: 'HEAD' }); if (response.ok) { const existing = results.get(query) || []; existing.push({ marketplace }); results.set(query, existing); } } catch { // Skip this marketplace } } } return results; } /** * List all available MCPs from a marketplace * Note: Requires marketplace to have a .marketplace/photons.json file */ async listFromMarketplace(marketplaceName: string): Promise<string[]> { const marketplace = this.get(marketplaceName); if (!marketplace) { return []; } try { // Try to fetch photons.json manifest const manifest = await this.fetchManifest(marketplace); if (manifest) { return manifest.photons.map(p => p.name); } } catch { // No manifest file available } return []; } /** * Save installation metadata for a Photon */ async savePhotonMetadata( fileName: string, marketplace: Marketplace, metadata: PhotonMetadata, contentHash: string ): Promise<void> { const localMetadata = await readLocalMetadata(); localMetadata.photons[fileName] = { marketplace: marketplace.name, marketplaceRepo: marketplace.repo, version: metadata.version, originalHash: metadata.hash || contentHash, installedAt: new Date().toISOString(), }; await writeLocalMetadata(localMetadata); } /** * Get local installation metadata for a Photon */ async getPhotonInstallMetadata(fileName: string): Promise<PhotonInstallMetadata | null> { const localMetadata = await readLocalMetadata(); return localMetadata.photons[fileName] || null; } /** * Check if a Photon file has been modified since installation */ async isPhotonModified(filePath: string, fileName: string): Promise<boolean> { const metadata = await this.getPhotonInstallMetadata(fileName); if (!metadata) { return false; // No metadata, can't determine } try { const currentHash = await calculateFileHash(filePath); return currentHash !== metadata.originalHash; } catch { return false; } } /** * Find all marketplaces that have a specific MCP (for conflict detection) */ async findAllSources(mcpName: string): Promise<Array<{ marketplace: Marketplace; metadata?: PhotonMetadata; content?: string }>> { const sources = []; const enabled = this.getEnabled(); for (const marketplace of enabled) { try { let content: string | null = null; if (marketplace.sourceType === 'local') { // Local filesystem const localPath = marketplace.url.replace('file://', ''); const mcpPath = path.join(localPath, `${mcpName}.photon.ts`); if (existsSync(mcpPath)) { content = await fs.readFile(mcpPath, 'utf-8'); } } else { // Remote fetch (GitHub, URL) const url = `${marketplace.url}/${mcpName}.photon.ts`; const response = await fetch(url); if (response.ok) { content = await response.text(); } } if (content) { // Try to fetch metadata from manifest const manifest = await this.getCachedManifest(marketplace.name); const metadata = manifest?.photons.find(p => p.name === mcpName); sources.push({ marketplace, metadata, content, }); } } catch { // Skip marketplace on error } } return sources; } /** * Detect all MCP conflicts across marketplaces */ async detectAllConflicts(): Promise<Map<string, Array<{ marketplace: Marketplace; metadata?: PhotonMetadata }>>> { const conflicts = new Map<string, Array<{ marketplace: Marketplace; metadata?: PhotonMetadata }>>(); const enabled = this.getEnabled(); if (enabled.length <= 1) { return conflicts; // No conflicts possible with 0 or 1 marketplace } // Collect all MCPs from all marketplaces const mcpsByName = new Map<string, Array<{ marketplace: Marketplace; metadata?: PhotonMetadata }>>(); for (const marketplace of enabled) { const manifest = await this.getCachedManifest(marketplace.name); if (manifest && manifest.photons) { for (const photon of manifest.photons) { if (!mcpsByName.has(photon.name)) { mcpsByName.set(photon.name, []); } mcpsByName.get(photon.name)!.push({ marketplace, metadata: photon, }); } } } // Find MCPs that appear in multiple marketplaces for (const [name, sources] of mcpsByName.entries()) { if (sources.length > 1) { conflicts.set(name, sources); } } return conflicts; } /** * Check if adding/upgrading an MCP would create a conflict */ async checkConflict(mcpName: string, targetMarketplace?: string): Promise<{ hasConflict: boolean; sources: Array<{ marketplace: Marketplace; metadata?: PhotonMetadata }>; recommendation?: string; }> { const sources = await this.findAllSources(mcpName); if (sources.length === 0) { return { hasConflict: false, sources: [] }; } if (sources.length === 1) { return { hasConflict: false, sources }; } // Multiple sources found - determine recommendation let recommendation: string | undefined; // If target marketplace specified, recommend it if (targetMarketplace) { const targetSource = sources.find(s => s.marketplace.name === targetMarketplace); if (targetSource) { recommendation = targetMarketplace; } } // Otherwise, recommend based on priority: version, then marketplace order if (!recommendation) { // Sort by version (semver) if available const withVersions = sources .filter(s => s.metadata?.version) .sort((a, b) => { const vA = a.metadata!.version; const vB = b.metadata!.version; return this.compareVersions(vB, vA); // Descending (newest first) }); if (withVersions.length > 0) { recommendation = withVersions[0].marketplace.name; } else { // Default to first enabled marketplace recommendation = sources[0].marketplace.name; } } return { hasConflict: true, sources, recommendation, }; } /** * Compare two semver versions * Returns: positive if v1 > v2, negative if v1 < v2, 0 if equal */ private compareVersions(v1: string, v2: string): number { const parts1 = v1.split('.').map(Number); const parts2 = v2.split('.').map(Number); for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return 1; if (p1 < p2) return -1; } return 0; } }

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