Skip to main content
Glama

SFCC Development MCP Server

by taurgis
sfra-client.ts•18.7 kB
/** * SFCC SFRA Documentation Client * * Provides access to SFRA (Storefront Reference Architecture) documentation including * core classes like Server, Request, Response, QueryString, render module, and comprehensive * model documentation for account, cart, products, pricing, billing, shipping, and more. */ import * as fs from 'fs/promises'; import * as path from 'path'; import { PathResolver } from '../utils/path-resolver.js'; import { CacheManager } from '../utils/cache.js'; import { Logger } from '../utils/logger.js'; export interface SFRADocument { title: string; description: string; sections: string[]; content: string; type: 'class' | 'module' | 'model'; category: 'core' | 'product' | 'order' | 'customer' | 'pricing' | 'store' | 'other'; properties?: string[]; methods?: string[]; filename: string; lastModified?: Date; } export interface SFRADocumentSummary { name: string; title: string; description: string; type: string; category: string; filename: string; } // Document categorization rules const CATEGORY_MAPPINGS: Record<string, string> = { // Core SFRA classes and modules 'server': 'core', 'request': 'core', 'response': 'core', 'querystring': 'core', 'render': 'core', // Product-related models 'product-full': 'product', 'product-bundle': 'product', 'product-tile': 'product', 'product-search': 'product', 'product-line-items': 'product', // Pricing models 'price-default': 'pricing', 'price-range': 'pricing', 'price-tiered': 'pricing', // Order and cart models 'cart': 'order', 'order': 'order', 'billing': 'order', 'shipping': 'order', 'payment': 'order', 'totals': 'order', // Customer models 'account': 'customer', 'address': 'customer', // Store models 'store': 'store', 'stores': 'store', // Other models 'categories': 'other', 'content': 'other', 'locale': 'other', }; /** * Enhanced client for accessing SFRA documentation with dynamic discovery */ export class SFRAClient { private cache: CacheManager; private docsPath: string; private documentsCache: Map<string, SFRADocument> = new Map(); private lastScanTime: number = 0; private static readonly SCAN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes private logger: Logger; constructor() { this.cache = new CacheManager(); this.docsPath = PathResolver.getSFRADocsPath(); this.logger = Logger.getChildLogger('SFRAClient'); } /** * Dynamically discover all available SFRA documentation files */ async getAvailableDocuments(): Promise<SFRADocumentSummary[]> { const cacheKey = 'sfra:available-documents-v2'; const cached = this.cache.getSearchResults(cacheKey); // Check if we need to rescan the filesystem const now = Date.now(); if (cached && (now - this.lastScanTime) < SFRAClient.SCAN_CACHE_TTL) { return cached; } try { const files = await fs.readdir(this.docsPath); const mdFiles = files.filter(file => file.endsWith('.md') && file !== 'README.md' && !file.startsWith('.'), ); const documents: SFRADocumentSummary[] = []; for (const filename of mdFiles) { try { const documentName = path.basename(filename, '.md'); const document = await this.getSFRADocumentMetadata(documentName); if (document) { documents.push({ name: documentName, title: document.title, description: document.description, type: document.type, category: document.category, filename: document.filename, }); } } catch (error) { this.logger.error(`Error processing SFRA document ${filename}:`, error); // Continue processing other files } } // Sort documents by category and then by name documents.sort((a, b) => { if (a.category !== b.category) { // Prioritize core documents if (a.category === 'core') {return -1;} if (b.category === 'core') {return 1;} return a.category.localeCompare(b.category); } return a.name.localeCompare(b.name); }); this.cache.setSearchResults(cacheKey, documents); this.lastScanTime = now; return documents; } catch (error) { this.logger.error('Error scanning SFRA documents directory:', error); return []; } } /** * Get lightweight metadata for a document without loading full content */ private async getSFRADocumentMetadata(documentName: string): Promise<SFRADocument | null> { // Normalize document name for consistent caching and lookup const normalizedDocumentName = documentName.toLowerCase(); // Check if we already have this document cached if (this.documentsCache.has(normalizedDocumentName)) { return this.documentsCache.get(normalizedDocumentName)!; } try { const filePath = await this.validateAndConstructPath(documentName); const stats = await fs.stat(filePath); // Check if we have a cached version that's still valid const cacheKey = `sfra:metadata:${normalizedDocumentName}`; const cached = this.cache.getFileContent(cacheKey); if (cached) { const cachedData = JSON.parse(cached); if (cachedData.lastModified && new Date(cachedData.lastModified) >= stats.mtime) { return cachedData; } } // Read only the first part of the file to extract metadata const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); // Extract title const titleLine = lines.find(line => line.startsWith('#')); const title = titleLine?.replace(/^#+\s*/, '').trim() ?? this.formatDocumentName(normalizedDocumentName); // Determine type based on title and content const type = this.determineDocumentType(title, content); // Determine category - use normalized name for consistent mapping const category = (CATEGORY_MAPPINGS[normalizedDocumentName] || 'other') as SFRADocument['category']; // Extract description (first substantial paragraph after title) const description = this.extractDescription(lines, title); // Extract sections (## headers) const sections = lines .filter(line => line.startsWith('##')) .map(line => line.replace(/^##\s*/, '').trim()) .filter(section => section.length > 0); const document: SFRADocument = { title, description, sections, content, // Keep full content for now, optimize later if needed type, category, filename: `${normalizedDocumentName}.md`, lastModified: stats.mtime, ...(type === 'class' || type === 'model' ? { properties: this.extractProperties(lines), methods: this.extractMethods(lines), } : {}), }; // Cache the metadata using normalized name this.cache.setFileContent(cacheKey, JSON.stringify(document)); this.documentsCache.set(normalizedDocumentName, document); return document; } catch (error) { this.logger.error(`Error loading SFRA document metadata ${normalizedDocumentName}:`, error); return null; } } /** * Get a specific SFRA document with full content */ async getSFRADocument(documentName: string): Promise<SFRADocument | null> { // Normalize document name for consistent lookup const normalizedDocumentName = documentName.toLowerCase(); // First try to get from metadata cache const metadata = await this.getSFRADocumentMetadata(documentName); if (!metadata) { return null; } // If the content is already loaded, return it if (metadata.content?.trim()) { return metadata; } // Otherwise, load the full content try { const filePath = await this.validateAndConstructPath(documentName); const content = await fs.readFile(filePath, 'utf-8'); const fullDocument: SFRADocument = { ...metadata, content, }; // Update cache using normalized name this.documentsCache.set(normalizedDocumentName, fullDocument); return fullDocument; } catch (error) { this.logger.error(`Error loading full SFRA document ${normalizedDocumentName}:`, error); return metadata; // Return metadata even if content loading failed } } /** * Enhanced search across all SFRA documentation with better categorization */ async searchSFRADocumentation(query: string): Promise<Array<{ document: string; title: string; category: string; type: string; relevanceScore: number; matches: Array<{section: string; content: string; lineNumber: number}>; }>> { const cacheKey = `sfra:search:${query.toLowerCase()}`; const cached = this.cache.getSearchResults(cacheKey); if (cached) {return cached;} const documents = await this.getAvailableDocuments(); const results = []; const queryLower = query.toLowerCase(); const queryWords = queryLower.split(/\s+/).filter(word => word.length > 1); for (const doc of documents) { const documentContent = await this.getSFRADocument(doc.name); if (!documentContent) {continue;} const matches = []; const lines = documentContent.content.split('\n'); let currentSection = ''; let relevanceScore = 0; // Calculate relevance score based on title and description matches if (doc.title.toLowerCase().includes(queryLower)) { relevanceScore += 10; } if (doc.description.toLowerCase().includes(queryLower)) { relevanceScore += 5; } // Search through content for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineLower = line.toLowerCase(); if (line.startsWith('##')) { currentSection = line.replace(/^##\s*/, '').trim(); } // Check for query matches let matchFound = false; let lineRelevance = 0; if (lineLower.includes(queryLower)) { matchFound = true; lineRelevance += 3; } else { // Check for partial matches with query words const wordMatches = queryWords.filter(word => lineLower.includes(word)); if (wordMatches.length > 0) { matchFound = true; lineRelevance += wordMatches.length; } } if (matchFound) { // Get context around the match const contextStart = Math.max(0, i - 2); const contextEnd = Math.min(lines.length, i + 3); const context = lines.slice(contextStart, contextEnd) .map((contextLine, idx) => { const actualLineNumber = contextStart + idx; return actualLineNumber === i ? `>>> ${contextLine}` : contextLine; }) .join('\n'); matches.push({ section: currentSection || 'Introduction', content: context, lineNumber: i + 1, }); relevanceScore += lineRelevance; } } if (matches.length > 0) { results.push({ document: doc.name, title: doc.title, category: doc.category, type: doc.type, relevanceScore, matches, }); } } // Sort by relevance score (highest first) results.sort((a, b) => b.relevanceScore - a.relevanceScore); this.cache.setSearchResults(cacheKey, results); return results; } /** * Get documents by category */ async getDocumentsByCategory(category: string): Promise<SFRADocumentSummary[]> { const allDocuments = await this.getAvailableDocuments(); return allDocuments.filter(doc => doc.category === category); } /** * Get all available categories */ async getAvailableCategories(): Promise<Array<{category: string; count: number; description: string}>> { const documents = await this.getAvailableDocuments(); const categoryMap = new Map<string, number>(); documents.forEach(doc => { categoryMap.set(doc.category, (categoryMap.get(doc.category) ?? 0) + 1); }); const categoryDescriptions = { 'core': 'Core SFRA classes and modules (Server, Request, Response, QueryString, render)', 'product': 'Product-related models and functionality', 'order': 'Order, cart, billing, shipping, and payment models', 'customer': 'Customer account and address models', 'pricing': 'Pricing and discount models', 'store': 'Store and location models', 'other': 'Other models and utilities', }; return Array.from(categoryMap.entries()).map(([category, count]) => ({ category, count, description: categoryDescriptions[category as keyof typeof categoryDescriptions] || 'Other documentation', })); } /** * Enhanced path validation and construction */ private async validateAndConstructPath(documentName: string): Promise<string> { if (!documentName || typeof documentName !== 'string') { throw new Error('Invalid document name: must be a non-empty string'); } if (documentName.includes('\0') || documentName.includes('\x00')) { throw new Error('Invalid document name: contains null bytes'); } if (documentName.includes('..') || documentName.includes('/') || documentName.includes('\\')) { throw new Error('Invalid document name: contains path traversal sequences'); } if (!/^[a-zA-Z0-9_-]+$/.test(documentName)) { throw new Error('Invalid document name: contains invalid characters'); } // Normalize document name to lowercase for case-insensitive lookup const normalizedDocumentName = documentName.toLowerCase(); const filePath = path.join(this.docsPath, `${normalizedDocumentName}.md`); const resolvedPath = path.resolve(filePath); const resolvedDocsPath = path.resolve(this.docsPath); if (!resolvedPath.startsWith(resolvedDocsPath)) { throw new Error('Invalid document name: path outside allowed directory'); } if (!resolvedPath.toLowerCase().endsWith('.md')) { throw new Error('Invalid document name: must reference a markdown file'); } return resolvedPath; } /** * Determine document type from title and content */ private determineDocumentType(title: string, content: string): 'class' | 'module' | 'model' { const titleLower = title.toLowerCase(); const contentLower = content.toLowerCase(); if (titleLower.includes('class ')) { return 'class'; } if (titleLower.includes('module ')) { return 'module'; } if (titleLower.includes('model') || contentLower.includes('model') || contentLower.includes('constructor') || contentLower.includes('properties')) { return 'model'; } return 'model'; // Default for most SFRA docs } /** * Extract description from document lines */ private extractDescription(lines: string[], title: string): string { const titleIndex = lines.findIndex(line => line.trim() === `# ${title}` || line.startsWith('#')); if (titleIndex === -1) { return 'No description available'; } // Look for overview section first const overviewIndex = lines.findIndex((line, index) => index > titleIndex && line.toLowerCase().includes('## overview'), ); if (overviewIndex !== -1) { // Get content under Overview section let descriptionEnd = lines.findIndex((line, index) => index > overviewIndex + 1 && line.startsWith('##'), ); if (descriptionEnd === -1) { descriptionEnd = Math.min(lines.length, overviewIndex + 10); } const overviewContent = lines.slice(overviewIndex + 1, descriptionEnd) .filter(line => line.trim() && !line.startsWith('#')) .join(' ') .trim(); if (overviewContent) { return overviewContent.substring(0, 300) + (overviewContent.length > 300 ? '...' : ''); } } // Fallback to first paragraph after title let descriptionStart = titleIndex + 1; while (descriptionStart < lines.length && !lines[descriptionStart].trim()) { descriptionStart++; } const descriptionEnd = lines.findIndex((line, index) => index > descriptionStart && (line.startsWith('#') || line.trim() === '')); const description = lines .slice(descriptionStart, descriptionEnd > -1 ? descriptionEnd : descriptionStart + 3) .filter(line => line.trim() && !line.startsWith('#')) .join(' ') .trim(); return description || 'No description available'; } /** * Extract properties from document content */ private extractProperties(lines: string[]): string[] { const properties: string[] = []; let inPropertiesSection = false; for (const line of lines) { if (line.toLowerCase().includes('## properties') || line.toLowerCase().includes('## property')) { inPropertiesSection = true; continue; } if (inPropertiesSection && line.startsWith('#') && !line.includes('properties')) { break; } if (inPropertiesSection && line.startsWith('### ')) { const property = line.replace('### ', '').trim(); if (!properties.includes(property)) { properties.push(property); } } } return properties; } /** * Extract methods from document content */ private extractMethods(lines: string[]): string[] { const methods: string[] = []; let inMethodSection = false; for (const line of lines) { if (line.toLowerCase().includes('## method') || line.toLowerCase().includes('## function')) { inMethodSection = true; continue; } if (inMethodSection && line.startsWith('#') && !line.toLowerCase().includes('method') && !line.toLowerCase().includes('function')) { break; } if (inMethodSection && line.startsWith('### ')) { const method = line.replace('### ', '').trim(); if (!methods.includes(method)) { methods.push(method); } } } return methods; } /** * Format document name for display */ private formatDocumentName(documentName: string): string { return documentName .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * Clear all caches */ clearCache(): void { this.cache.clearAll(); this.documentsCache.clear(); this.lastScanTime = 0; } }

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/taurgis/sfcc-dev-mcp'

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