Skip to main content
Glama

SAP Documentation MCP Server

by marianfoo
localDocs.tsโ€ข75.2 kB
// src/lib/localDocs.ts import fs from "fs/promises"; import { existsSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { SearchResponse, SearchResult } from "./types.js"; import { getFTSCandidateIds, getFTSStats } from "./searchDb.js"; import { searchCommunityBestMatch, getCommunityPostByUrl, getCommunityPostById, getCommunityPostsByIds, searchAndGetTopPosts, BestMatchHit } from "./communityBestMatch.js"; import { getDocUrlConfig, getAllDocUrlConfigs, getSourcePath, getAllContextBoosts, getContextBoosts, getAllContextEmojis, getContextEmoji, type DocUrlConfig } from "./metadata.js"; import { generateDocumentationUrl as generateUrl } from "./url-generation/index.js"; // Use the new URL generation system function generateDocumentationUrl(libraryId: string, relFile: string, content: string): string | null { const config = getDocUrlConfig(libraryId); if (!config) { return null; } return generateUrl(libraryId, relFile, content, config); } // Get the directory of this script and find the project root const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Find project root by looking for package.json let PROJECT_ROOT = __dirname; while (PROJECT_ROOT !== path.dirname(PROJECT_ROOT)) { try { if (existsSync(path.join(PROJECT_ROOT, 'package.json'))) { break; } } catch { // Continue searching } PROJECT_ROOT = path.dirname(PROJECT_ROOT); } // Fallback: assume dist structure if (!existsSync(path.join(PROJECT_ROOT, 'package.json'))) { PROJECT_ROOT = path.resolve(__dirname, "../../.."); } const DATA_DIR = path.join(PROJECT_ROOT, "dist", "data"); // Note: SAP Community search now uses HTML scraping via communityBestMatch.ts // Search SAP Community for relevant posts using HTML scraping async function searchSAPCommunity(query: string): Promise<SearchResult[]> { try { const hits: BestMatchHit[] = await searchCommunityBestMatch(query, { includeBlogs: true, limit: 20, userAgent: 'SAP-Docs-MCP/1.0' }); return hits.map(hit => ({ library_id: hit.postId ? `community-${hit.postId}` : `community-url-${encodeURIComponent(hit.url)}`, topic: '', id: hit.postId ? `community-${hit.postId}` : `community-url-${encodeURIComponent(hit.url)}`, title: hit.title, url: hit.url, snippet: hit.snippet || '', score: 0, metadata: { source: 'community', postTime: hit.published, author: hit.author, likes: hit.likes, tags: hit.tags, totalSnippets: 1 }, // Legacy fields for backward compatibility description: hit.snippet || '', totalSnippets: 1, source: 'community', postTime: hit.published, author: hit.author, likes: hit.likes, tags: hit.tags })); } catch (error) { console.warn('Failed to search SAP Community:', error); return []; } } // Get full content of a community post using LiQL API async function getCommunityPost(postId: string): Promise<string | null> { try { // Handle both postId formats: "community-postId" and "community-url-encodedUrl" if (postId.startsWith('community-url-')) { // Extract URL from encoded format and fall back to URL scraping const encodedUrl = postId.replace('community-url-', ''); const postUrl = decodeURIComponent(encodedUrl); return await getCommunityPostByUrl(postUrl, 'SAP-Docs-MCP/1.0'); } else { // For standard post IDs, use the efficient LiQL API const numericId = postId.replace('community-', ''); return await getCommunityPostById(numericId, 'SAP-Docs-MCP/1.0'); } } catch (error) { console.warn('Failed to get community post:', error); return null; } } // Helper function to parse document ID into library_id and topic function parseDocumentId(docId: string): { libraryId: string; topic: string } { // docId formats: // - "/cap" -> libraryId="/cap", topic="" // - "/cap/guides/domain-modeling#compositions" -> libraryId="/cap", topic="guides/domain-modeling#compositions" // - "/openui5-api/sap/m/Button" -> libraryId="/openui5-api", topic="sap/m/Button" // Find the first slash after the initial slash const match = docId.match(/^(\/[^\/]+)(?:\/(.*))?$/); if (match) { const [, libraryId, topic = ""] = match; return { libraryId, topic }; } // Fallback: treat the whole thing as library_id return { libraryId: docId, topic: "" }; } // Helper function to format search result entries with clear library_id and topic function formatSearchResultEntry(result: any, queryContext: string): string { const { libraryId, topic } = parseDocumentId(result.docId); const score = result.score.toFixed(0); const description = result.docDescription.substring(0, 100); const contextEmoji = getContextEmoji(queryContext); let formatted = `${contextEmoji} **${result.docTitle}** (Score: ${score})\n`; formatted += ` ๐Ÿ“„ ${description}${description.length >= 100 ? '...' : ''}\n`; formatted += ` ๐Ÿ“‚ Library: \`${libraryId}\`\n`; if (topic) { formatted += ` ๐ŸŽฏ Topic: \`${topic}\`\n`; formatted += ` โœ… **Call:** \`sap_docs_get(library_id="${libraryId}", topic="${topic}")\`\n\n`; } else { formatted += ` โœ… **Call:** \`sap_docs_get(library_id="${libraryId}")\`\n\n`; } return formatted; } // Format JavaScript content for better readability in documentation context function formatJSDocContent(content: string, controlName: string): string { const lines = content.split('\n'); const result: string[] = []; result.push(`# ${controlName} - OpenUI5 Control API`); result.push(''); // Extract main JSDoc comment const mainJSDocMatch = content.match(/\/\*\*\s*([\s\S]*?)\*\//); if (mainJSDocMatch) { const cleanDoc = mainJSDocMatch[1] .split('\n') .map(line => line.replace(/^\s*\*\s?/, '')) .join('\n') .trim(); result.push('## Description'); result.push(''); result.push(cleanDoc); result.push(''); } // Extract metadata section const metadataMatch = content.match(/metadata\s*:\s*\{([\s\S]*?)\n\s*\}/); if (metadataMatch) { result.push('## Control Metadata'); result.push(''); result.push('```javascript'); result.push('metadata: {'); result.push(metadataMatch[1]); result.push('}'); result.push('```'); result.push(''); } // Extract properties const propertiesMatch = content.match(/properties\s*:\s*\{([\s\S]*?)\n\s*\}/); if (propertiesMatch) { result.push('## Properties'); result.push(''); result.push('```javascript'); result.push(propertiesMatch[1]); result.push('```'); result.push(''); } // Extract events const eventsMatch = content.match(/events\s*:\s*\{([\s\S]*?)\n\s*\}/); if (eventsMatch) { result.push('## Events'); result.push(''); result.push('```javascript'); result.push(eventsMatch[1]); result.push('```'); result.push(''); } // Extract aggregations const aggregationsMatch = content.match(/aggregations\s*:\s*\{([\s\S]*?)\n\s*\}/); if (aggregationsMatch) { result.push('## Aggregations'); result.push(''); result.push('```javascript'); result.push(aggregationsMatch[1]); result.push('```'); result.push(''); } // Extract associations const associationsMatch = content.match(/associations\s*:\s*\{([\s\S]*?)\n\s*\}/); if (associationsMatch) { result.push('## Associations'); result.push(''); result.push('```javascript'); result.push(associationsMatch[1]); result.push('```'); result.push(''); } result.push('---'); result.push(''); result.push('### Full Source Code'); result.push(''); result.push('```javascript'); result.push(content); result.push('```'); return result.join('\n'); } // Format sample content for better readability in documentation context function formatSampleContent(content: string, filePath: string, title: string): string { const fileExt = path.extname(filePath); const controlName = title.split(' ')[0]; // Extract control name from title const fileName = path.basename(filePath); const result: string[] = []; result.push(`# ${title}`); result.push(''); // Add file information result.push(`## File Information`); result.push(`- **File**: \`${fileName}\``); result.push(`- **Type**: ${fileExt.slice(1).toUpperCase()} ${getFileTypeDescription(fileExt)}`); result.push(`- **Control**: ${controlName}`); result.push(''); // Add description based on file type if (fileExt === '.js') { if (content.toLowerCase().includes('controller')) { result.push(`## Controller Implementation`); result.push(`This file contains the controller logic for the ${controlName} sample.`); } else if (content.toLowerCase().includes('component')) { result.push(`## Component Definition`); result.push(`This file defines the application component for the ${controlName} sample.`); } else { result.push(`## JavaScript Implementation`); result.push(`This file contains JavaScript code for the ${controlName} sample.`); } } else if (fileExt === '.xml') { result.push(`## XML View`); result.push(`This file contains the XML view definition for the ${controlName} sample.`); } else if (fileExt === '.json') { result.push(`## JSON Configuration`); if (fileName.includes('manifest')) { result.push(`This file contains the application manifest configuration.`); } else { result.push(`This file contains JSON configuration or sample data.`); } } else if (fileExt === '.html') { result.push(`## HTML Page`); result.push(`This file contains the HTML page setup for the ${controlName} sample.`); } result.push(''); // Add the actual content with syntax highlighting result.push(`## Source Code`); result.push(''); result.push(`\`\`\`${getSyntaxHighlighting(fileExt)}`); result.push(content); result.push('```'); // Add usage tips result.push(''); result.push('## Usage Tips'); result.push(''); if (fileExt === '.js' && content.includes('onPress')) { result.push('- This sample includes event handlers (onPress methods)'); } if (fileExt === '.xml' && content.includes('{')) { result.push('- This view uses data binding patterns'); } if (content.toLowerCase().includes('model')) { result.push('- This sample demonstrates model usage'); } if (content.toLowerCase().includes('router') || content.toLowerCase().includes('routing')) { result.push('- This sample includes routing configuration'); } return result.join('\n'); } function getFileTypeDescription(ext: string): string { switch (ext) { case '.js': return 'JavaScript'; case '.xml': return 'XML View'; case '.json': return 'JSON Configuration'; case '.html': return 'HTML Page'; default: return 'File'; } } function getSyntaxHighlighting(ext: string): string { switch (ext) { case '.js': return 'javascript'; case '.xml': return 'xml'; case '.json': return 'json'; case '.html': return 'html'; default: return 'text'; } } type LibraryBundle = { id: string; name: string; description: string; docs: { id: string; title: string; description: string; snippetCount: number; relFile: string; }[]; }; let INDEX: Record<string, LibraryBundle> | null = null; async function loadIndex() { if (!INDEX) { const raw = await fs.readFile(path.join(DATA_DIR, "index.json"), "utf8"); INDEX = JSON.parse(raw) as Record<string, LibraryBundle>; } return INDEX; } // Utility: Expand query with synonyms and related terms function expandQuery(query: string): string[] { const synonyms: Record<string, string[]> = { // === UI5 CONTROL TERMS === // Wizard-related terms (UI5 only) wizard: ["wizard", "sap.m.Wizard", "WizardStep", "sap.m.WizardStep", "WizardRenderMode", "sap.ui.webc.fiori.IWizardStep", "sap.ui.webc.fiori.WizardContentLayout", "wizard control", "wizard fragment", "wizard step", "step wizard", "multi-step"], // Button-related terms (UI5 + general) button: ["button", "sap.m.Button", "sap.ui.webc.main.Button", "button control", "button press", "action button", "toggle button", "click", "press event"], // Table-related terms (UI5 + CAP) table: ["table", "sap.m.Table", "sap.ui.table.Table", "sap.ui.table.TreeTable", "table control", "table row", "data table", "tree table", "grid table", "entity", "table entity", "database table", "cds table"], // === CAP-SPECIFIC TERMS === // CDS and modeling cds: ["cds", "Core Data Services", "cds model", "cds file", "schema", "data model", "entity", "service", "view", "type", "aspect", "composition", "association"], entity: ["entity", "cds entity", "data entity", "business entity", "table", "model", "schema", "composition", "association", "key", "managed"], service: ["service", "cds service", "odata service", "rest service", "api", "endpoint", "business service", "application service", "crud"], aspect: ["aspect", "cds aspect", "managed", "cuid", "audited", "reuse aspect", "mixin", "common aspect"], temporal: ["temporal", "temporal data", "time slice", "valid from", "valid to", "temporal entity", "temporal aspect", "time travel", "as-of-now"], annotation: ["annotation", "annotations", "@", "annotation file", "annotation target", "UI annotation", "Common annotation", "Capabilities annotation", "odata annotation", "fiori annotation"], // CAP Authentication & Security auth: ["authentication", "authorization", "auth", "security", "user", "role", "scopes", "jwt", "oauth", "saml", "xsuaa", "ias", "passport", "login"], // CAP Database & Persistence database: ["database", "db", "hana", "sqlite", "postgres", "h2", "persistence", "connection", "schema", "migration", "deploy"], deployment: ["deployment", "deploy", "cf push", "cloud foundry", "kubernetes", "helm", "docker", "mta", "build", "production"], // === WDI5-SPECIFIC TERMS === testing: ["testing", "test", "e2e", "end-to-end", "integration test", "ui test", "browser test", "selenium", "webdriver", "automation", "test framework"], wdi5: ["wdi5", "webdriver", "ui5 testing", "browser automation", "page object", "test framework", "selector", "locator", "element", "assertion", "wdio", "ui5 test api", "sap.ui.test", "test library", "fe-testlib"], selector: ["selector", "locator", "element", "control selector", "id", "property", "binding", "aggregation", "wdi5 selector", "ui5 control", "matcher", "sap.ui.test.matchers", "byId", "byProperty", "byBinding"], browser: ["browser", "chrome", "firefox", "safari", "webdriver", "headless", "viewport", "screenshot", "debugging", "browser automation"], pageobject: ["page object", "pageobject", "page objects", "test structure", "test organization", "test patterns", "ui5 test patterns"], // === CROSS-PLATFORM TERMS === // Navigation & Routing (UI5 + CAP) routing: ["routing", "router", "navigation", "route", "target", "pattern", "manifest routing", "app router", "destination"], // Forms (UI5 + CAP + wdi5) form: ["form", "sap.ui.layout.form.Form", "sap.ui.layout.form.SimpleForm", "form control", "form layout", "smart form", "input validation", "form testing"], // Data & Models (UI5 + CAP) model: ["model", "data model", "json model", "odata model", "entity model", "binding", "property binding", "aggregation binding"], // Fiori & Elements fiori: ["fiori", "fiori elements", "list report", "object page", "overview page", "analytical list page", "worklist", "freestyle fiori", "fiori launchpad"], // Development & Tools development: ["development", "dev", "local", "debugging", "console", "devtools", "hot reload", "live reload", "build", "compile"] }; const q = query.toLowerCase().trim(); // Check for exact matches first for (const [key, values] of Object.entries(synonyms)) { if (q === key || values.some(v => v.toLowerCase() === q)) { return values; } } // Generic approach: Build smart query variations with term prioritization const queryTerms = q.toLowerCase().split(/\s+/); const importantMatches: string[] = []; const supplementaryMatches: string[] = []; let hasSpecificMatch = false; for (const [key, values] of Object.entries(synonyms)) { // Check if query contains this key term const keyLower = key.toLowerCase(); // Avoid substring false-positives for certain keys (e.g., 'form' in 'information') const wholeWordMatch = keyLower === 'form' ? /\bforms?\b/.test(q) : q.includes(keyLower); if (wholeWordMatch || values.some(v => q.includes(v.toLowerCase()))) { // If the key term is a major word in the query, prioritize it const isMajorWord = queryTerms.includes(keyLower) || queryTerms.some(term => term.length > 3 && keyLower.includes(term)); if (isMajorWord) { importantMatches.unshift(...values); hasSpecificMatch = true; } else { // Minor/partial matches go to supplementary supplementaryMatches.push(...values); } } } if (importantMatches.length > 0 || supplementaryMatches.length > 0) { // Always start with original query variations, then important matches, then supplementary const result = [ query, // Original exact query first query.toLowerCase(), ...new Set(importantMatches), // Important domain-specific terms ...new Set(supplementaryMatches.slice(0, 5)) // Limit supplementary to avoid pollution ]; return result; } // Handle common UI5 control patterns const ui5ControlPattern = /^sap\.[a-z]+\.[A-Z][a-zA-Z0-9]*$/; if (ui5ControlPattern.test(query)) { const controlName = query.split('.').pop()!; return [query, controlName, controlName.toLowerCase(), `${controlName} control`]; } // Generate contextual variations based on query type const variations = [query, query.toLowerCase()]; // Add technology-specific variations if (q.includes('cap') || q.includes('cds')) { variations.push('CAP', 'cds', 'Core Data Services', 'service', 'entity'); } if (q.includes('wdi5') || q.includes('test') || q.includes('testing') || q.includes('e2e')) { variations.push('wdi5', 'testing', 'e2e', 'webdriver', 'ui5 testing', 'wdio', 'pageobject', 'selector', 'locator'); } if ((q.includes('ui5') || q.includes('sap.')) && !q.includes('web components') && !q.includes('web component')) { variations.push('UI5', 'SAPUI5', 'OpenUI5', 'control', 'Fiori'); } if (q.includes('web components') || q.includes('ui5-') || q.includes('@ui5/') || q.includes('web component')) { variations.push('UI5 Web Components', 'Web Components', 'Web Component', 'component'); } if (q.includes('sdk') || q.includes('cloud sdk')) { variations.push('SAP Cloud SDK', 'SAP Cloud SDK for AI'); } if ((q.includes('ui5') && q.includes('tooling')) || (q.includes('ui5') && q.includes('cli'))) { variations.push('UI5 Tooling', 'UI5 CLI', 'tasks', 'middleware', 'shims'); } if (q.includes('mta') || q.includes('build') || q.includes('multitarget') || q.includes('mtar')) { variations.push('MTA', 'MTA Build', 'MTA Build Tool', 'Cloud MTA Build Tool', 'Multitarget Application', 'Multitarget Application Archive Builder', 'mtar'); } // Add common variations variations.push( query.charAt(0).toUpperCase() + query.slice(1).toLowerCase(), query.replace(/[_-]/g, ' '), query.replace(/\s+/g, ''), ...query.split(/[_\s-]/).filter(part => part.length > 2) ); return [...new Set(variations)]; } // Utility: Extract control names/properties from file content (XML/JS) function extractControlsFromContent(content: string): string[] { const controls = new Set<string>(); // XML: <sap.m.Wizard ...> or <Wizard ...> const xmlMatches = content.matchAll(/<([a-zA-Z0-9_.:]+)[\s>]/g); for (const m of xmlMatches) { let tag = m[1]; if (tag.includes(":")) tag = tag.split(":").pop()!; controls.add(tag); } // JS: Enhanced pattern matching for all UI5 namespaces const sapNamespaces = ['sap.m', 'sap.ui', 'sap.f', 'sap.tnt', 'sap.suite', 'sap.viz', 'sap.uxap']; for (const namespace of sapNamespaces) { const pattern = new RegExp(`${namespace.replace('.', '\\.')}\.([A-Za-z0-9_]+)`, 'g'); const matches = content.matchAll(pattern); for (const m of matches) { controls.add(`${namespace}.${m[1]}`); controls.add(m[1]); // Also add just the control name } } // Extract from extend() calls const extendMatches = content.matchAll(/\.extend\s*\(\s*["']([^"']+)["']/g); for (const m of extendMatches) { controls.add(m[1]); const controlName = m[1].split('.').pop(); if (controlName) controls.add(controlName); } // Extract control names from metadata const metadataMatch = content.match(/metadata\s*:\s*\{[\s\S]*?library\s*:\s*["']([^"']+)["'][\s\S]*?\}/); if (metadataMatch) { controls.add(metadataMatch[1]); } return Array.from(controls); } // Utility: Calculate similarity score between strings function calculateSimilarity(str1: string, str2: string): number { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); // Exact match if (s1 === s2) return 100; // One contains the other if (s1.includes(s2) || s2.includes(s1)) return 90; // Levenshtein distance for fuzzy matching const matrix = Array(s2.length + 1).fill(null).map(() => Array(s1.length + 1).fill(null)); for (let i = 0; i <= s1.length; i++) matrix[0][i] = i; for (let j = 0; j <= s2.length; j++) matrix[j][0] = j; for (let j = 1; j <= s2.length; j++) { for (let i = 1; i <= s1.length; i++) { const indicator = s1[i - 1] === s2[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j][i - 1] + 1, // deletion matrix[j - 1][i] + 1, // insertion matrix[j - 1][i - 1] + indicator // substitution ); } } const distance = matrix[s2.length][s1.length]; const maxLength = Math.max(s1.length, s2.length); return Math.max(0, (maxLength - distance) / maxLength * 100); } // Utility: Enhanced control matching with fuzzy matching function isControlMatch(controlName: string, query: string): boolean { const name = controlName.toLowerCase(); const q = query.toLowerCase(); // Exact match if (name === q) return true; // Direct contains if (name.includes(q)) return true; // Check if query matches the control class name const controlParts = name.split('.'); const lastPart = controlParts[controlParts.length - 1]; if (lastPart && (lastPart === q || lastPart.includes(q))) return true; // Check for fuzzy similarity (threshold: 70%) if (calculateSimilarity(name, q) > 70) return true; if (lastPart && calculateSimilarity(lastPart, q) > 70) return true; // Check for common UI5 control patterns with synonyms const controlKeywords = [ 'wizard', 'button', 'table', 'input', 'list', 'panel', 'dialog', 'form', 'navigation', 'layout', 'chart', 'page', 'app', 'shell', 'toolbar', 'menu', 'container', 'text', 'label', 'image', 'card', 'tile', 'icon', 'bar', 'picker', 'select', 'switch', 'slider', 'progress', 'busy', 'message', 'notification', 'popover', 'tooltip', 'breadcrumb', 'rating', 'feed', 'upload', 'calendar', 'date', 'time', 'color', 'file', 'search' ]; // Check if query is a control keyword and the control name contains it if (controlKeywords.includes(q)) { return controlKeywords.some(kw => name.includes(kw)); } return false; } // Determine the primary context of a query for smart filtering function determineQueryContext(originalQuery: string, expandedQueries: string[]): string { const q = originalQuery.toLowerCase(); const allQueries = [originalQuery, ...expandedQueries].map(s => s.toLowerCase()); const contextScores: { context: string, score: number }[] = []; // Check for UI5 annotation qualifiers (e.g., "UI.Chart #SpecificationWidthColumnChart") // These should be treated as UI5/SAPUI5 context, not wdi5/testing context const isAnnotationQualifier = /UI\.\w+\s*#\w+|@UI\.\w+|UI\s+\w+\s*#|annotation.*#/.test(originalQuery); // CAP context indicators const capIndicators = ['cds', 'cap', 'entity', 'service', 'aspect', 'annotation', 'odata', 'hana']; const capScore = capIndicators.filter(term => allQueries.some(query => query.includes(term)) ).length; contextScores.push({ context: 'CAP', score: capScore }); // wdi5 context indicators - but reduce weight if this looks like an annotation qualifier const wdi5Indicators = ['wdi5', 'test', 'testing', 'e2e', 'browser', 'webdriver', 'selenium', 'automation', 'wdio', 'pageobject', 'selector', 'locator', 'assertion', 'fe-testlib']; let wdi5Score = wdi5Indicators.filter(term => allQueries.some(query => query.includes(term)) ).length; // Reduce wdi5 score if this looks like an annotation qualifier if (isAnnotationQualifier) { wdi5Score = Math.max(0, wdi5Score - 2); // Reduce by 2 to counter false positives from 'selector' } contextScores.push({ context: 'wdi5', score: wdi5Score }); // UI5 context indicators - boost score if this looks like an annotation qualifier const ui5Indicators = ['sap.m', 'sap.ui', 'sap.f', 'control', 'wizard', 'button', 'table', 'fiori', 'ui5', 'sapui5', 'chart', 'micro']; let ui5Score = ui5Indicators.filter(term => allQueries.some(query => query.includes(term)) ).length; // Boost UI5 score for annotation qualifiers since they're typically UI5/Fiori Elements if (isAnnotationQualifier) { ui5Score += 2; } contextScores.push({ context: 'UI5', score: ui5Score }); // UI5 Web Components context indicators const ui5WebComponentsIndicators = ['ui5 web components','web components','ui5-', '@ui5/', 'web-component', 'web-components', 'component']; const ui5WebComponentsScore = ui5WebComponentsIndicators.filter(term => allQueries.some(query => query.includes(term)) ).length; contextScores.push({ context: 'UI5 Web Components', score: ui5WebComponentsScore }); // SAP Cloud SDK context indicators const sapCloudSdkIndicators = ['cloud sdk', 'sdk', 'cloud', 'sdk for ai', 'ai']; const sapCloudSdkScore = sapCloudSdkIndicators.filter(term => allQueries.some(query => query.includes(term)) ).length; contextScores.push({ context: 'SAP Cloud SDK', score: sapCloudSdkScore }); // UI5 Tooling context indicators const ui5ToolingIndicators = ['ui5', 'ui5-', '@ui5/', 'tooling', 'cli', 'tasks', 'middleware', 'shims']; const ui5ToolingScore = ui5ToolingIndicators.filter(term => allQueries.some(query => query.includes(term)) ).length; contextScores.push({ context: 'UI5 Tooling', score: ui5ToolingScore }); // Cloud MTA Build Tool context indicators const mtaIndicators = ['mta', 'mtar', 'multitarget', 'build', 'cloud mta build tool']; const mtaScore = mtaIndicators.filter(term => allQueries.some(query => query.includes(term)) ).length; contextScores.push({ context: 'Cloud MTA Build Tool', score: mtaScore }); // Sort by score and return the strongest context, if the first two are the same, return 'MIXED' contextScores.sort((a, b) => b.score - a.score); if (contextScores[0].score === contextScores[1].score) return 'MIXED'; return contextScores[0].context; // Return strongest context /*if (capScore > 0 && capScore >= wdi5Score && capScore >= ui5Score && capScore >= ui5WebComponentsScore) return 'CAP'; if (wdi5Score > 0 && wdi5Score >= capScore && wdi5Score >= ui5Score && wdi5Score >= ui5WebComponentsScore) return 'wdi5'; if (ui5Score > 0 && ui5Score >= ui5WebComponentsScore) return 'UI5'; if (ui5WebComponentsScore > 0) return 'UI5 Web Components'; return 'MIXED'; // No clear context*/ } // Soft priors per library to bias scores by detected intent without hard-filtering function computeLibraryPriors(queryContext: string): Record<string, number> { // Get baseline priors from metadata (all sources get 0.05 baseline) const allContextBoosts = getAllContextBoosts(); const priors: Record<string, number> = {}; // Initialize baseline priors for all known library IDs const allBoosts = allContextBoosts; const allLibraryIds = new Set<string>(); Object.values(allBoosts).forEach(contextBoosts => { Object.keys(contextBoosts).forEach(libId => allLibraryIds.add(libId)); }); // Set baseline priors allLibraryIds.forEach(libId => { priors[libId] = 0.05; }); // Apply context-specific boosts from metadata const contextBoosts = getContextBoosts(queryContext); Object.entries(contextBoosts).forEach(([libId, boost]) => { priors[libId] = Math.max(priors[libId] || 0, boost); }); return priors; } // Apply context-aware penalties/boosts function applyContextPenalties(score: number, libraryId: string, queryContext: string, query: string): number { const q = query.toLowerCase(); // Strong penalties for off-context matches if (queryContext === 'CAP') { if (libraryId === '/openui5-api' || libraryId === '/openui5-samples') { // Penalize UI5 results for CAP queries unless they're integration-related if (!q.includes('ui5') && !q.includes('fiori') && !q.includes('integration')) { score *= 0.3; // 70% penalty } } if (libraryId === '/wdi5') { score *= 0.5; // 50% penalty for wdi5 on CAP queries } } else if (queryContext === 'wdi5') { if (libraryId === '/openui5-api' || libraryId === '/openui5-samples') { // Heavily penalize UI5 API for wdi5 queries unless testing-related if (!q.includes('testing') && !q.includes('test') && !q.includes('ui5')) { score *= 0.2; // 80% penalty } } if (libraryId === '/cap') { score *= 0.4; // 60% penalty for CAP on wdi5 queries } } else if (queryContext === 'UI5') { if (libraryId === '/cap') { // Penalize CAP for pure UI5 queries unless integration-related if (!q.includes('service') && !q.includes('odata') && !q.includes('backend')) { score *= 0.4; // 60% penalty } } if (libraryId === '/wdi5') { // Penalize wdi5 for UI5 queries unless testing-related if (!q.includes('test') && !q.includes('testing')) { score *= 0.3; // 70% penalty } } } return score; } export async function searchLibraries(query: string, fileContent?: string): Promise<SearchResponse> { const index = await loadIndex(); let queries = expandQuery(query); // Generic query prioritization: ensure original user query is always first and most important const originalQuery = query.trim(); const lowercaseQuery = originalQuery.toLowerCase(); // Remove duplicates and ensure original query variants come first queries = [ originalQuery, // User's exact query (highest priority) lowercaseQuery, // Lowercase version ...queries.filter(q => q !== originalQuery && q !== lowercaseQuery) ]; // If file content is provided, extract controls/properties and add to queries if (fileContent) { const extracted = extractControlsFromContent(fileContent); queries = [...new Set([...queries, ...extracted])]; } let allMatches: Array<any> = []; let triedQueries: string[] = []; // Determine query context for smart filtering const queryContext = determineQueryContext(query, queries); // HYBRID FTS APPROACH: Use FTS for fast candidate filtering, then apply sophisticated scoring let candidateDocIds = new Set<string>(); let usedFTS = false; try { // Prefer relevant libraries based on detected context const preferredLibs: string[] = []; if (queryContext === 'SAP Cloud SDK') { preferredLibs.push('/cloud-sdk-js','/cloud-sdk-java','/cloud-sdk-ai-js','/cloud-sdk-ai-java'); } else if (queryContext === 'UI5') { preferredLibs.push('/sapui5','/openui5-api','/openui5-samples'); } else if (queryContext === 'wdi5') { preferredLibs.push('/wdi5'); } else if (queryContext === 'UI5 Web Components') { preferredLibs.push('/ui5-webcomponents'); } else if (queryContext === 'UI5 Tooling') { preferredLibs.push('/ui5-tooling'); } else if (queryContext === 'Cloud MTA Build Tool') { preferredLibs.push('/cloud-mta-build-tool'); } else if (queryContext === 'CAP') { preferredLibs.push('/cap'); } // Get FTS candidates with smart prioritization for (let i = 0; i < queries.length; i++) { const q = queries[i]; // Higher limit for original/important queries, lower for supplementary const limit = i < 3 ? 200 : 80; // First 3 queries get more candidates const ftsResults = getFTSCandidateIds(q, { libraries: preferredLibs.length ? preferredLibs : undefined }, limit); if (ftsResults.length > 0) { // For original query (first), add all candidates // For others, prioritize candidates that aren't already included if (i === 0) { // Original query gets full priority ftsResults.forEach(id => candidateDocIds.add(id)); } else { // Add new candidates, but don't overwhelm let added = 0; for (const id of ftsResults) { if (!candidateDocIds.has(id) && added < 50) { candidateDocIds.add(id); added++; } } } usedFTS = true; } } // If FTS found no candidates or failed, fall back to searching everything if (candidateDocIds.size === 0) { console.log("FTS found no candidates, falling back to full search"); usedFTS = false; } } catch (error) { console.warn("FTS search failed, falling back to full search:", error); usedFTS = false; } // Score matches more comprehensively with context awareness for (const q of queries) { triedQueries.push(q); // Search across all documents in all libraries (filtered by FTS candidates if available) for (const lib of Object.values(index)) { // Check if library name/description matches const libNameSimilarity = calculateSimilarity(lib.name, q); const libDescSimilarity = calculateSimilarity(lib.description, q); if (libNameSimilarity > 60 || libDescSimilarity > 40) { allMatches.push({ score: Math.max(libNameSimilarity, libDescSimilarity), libraryId: lib.id, libraryName: lib.name, docId: lib.id, docTitle: `${lib.name} (Full Library)`, docDescription: lib.description, matchType: libNameSimilarity > libDescSimilarity ? 'Library Name' : 'Library Description', snippetCount: lib.docs.reduce((s, d) => s + d.snippetCount, 0), source: 'docs' }); } // Search within individual documents with enhanced scoring for (const doc of lib.docs) { // If we're using FTS filtering, only process documents that are in the candidate set if (usedFTS && !candidateDocIds.has(doc.id)) { // Also check if any section of this document is in the candidate set const hasSectionCandidate = Array.from(candidateDocIds).some(candidateId => candidateId.startsWith(doc.id + '#') ); if (!hasSectionCandidate) { continue; } } let score = 0; let matchType = ''; // Calculate similarity scores for different aspects const titleSimilarity = calculateSimilarity(doc.title, q); const descSimilarity = calculateSimilarity(doc.description, q); // Check enhanced metadata fields if available (new index format) const docAny = doc as any; const controlName = docAny.controlName; const keywords = docAny.keywords || []; const properties = docAny.properties || []; const events = docAny.events || []; let keywordMatch = false; let controlNameMatch = false; let propertyMatch = false; // Check keyword matches if (keywords.length > 0) { keywordMatch = keywords.some((kw: string) => kw.toLowerCase() === q.toLowerCase() || kw.toLowerCase().includes(q.toLowerCase()) || calculateSimilarity(kw.toLowerCase(), q.toLowerCase()) > 80 ); } // Check control name matches if (controlName) { controlNameMatch = controlName.toLowerCase() === q.toLowerCase() || controlName.toLowerCase().includes(q.toLowerCase()) || calculateSimilarity(controlName.toLowerCase(), q.toLowerCase()) > 80; } // Check property/event matches if (properties.length > 0 || events.length > 0) { const allProps = [...properties, ...events]; propertyMatch = allProps.some((prop: string) => prop.toLowerCase() === q.toLowerCase() || calculateSimilarity(prop.toLowerCase(), q.toLowerCase()) > 75 ); } // Enhanced generic scoring for better relevance detection // 1. Check for multi-word query matches in title const queryWords = q.toLowerCase().split(/\s+/).filter(w => w.length > 2); const titleWords = doc.title.toLowerCase().split(/\s+/); const wordMatchCount = queryWords.filter(qw => titleWords.some(tw => tw.includes(qw) || qw.includes(tw)) ).length; const wordMatchRatio = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0; // 2. Check for phrase containment (e.g., "temporal entities" in "Declaring Temporal Entities") const titleContainsQuery = doc.title.toLowerCase().includes(q.toLowerCase()); const queryContainsTitle = q.toLowerCase().includes(doc.title.toLowerCase()); // 3. Exact matches get highest priority with heading level scoring if (doc.title.toLowerCase() === q.toLowerCase()) { // Base score for exact match score = 150; // Adjust score based on heading level for sections if ((doc as any).headingLevel) { const headingLevel = (doc as any).headingLevel; if (headingLevel === 2) { score = 160; // ## sections get highest score } else if (headingLevel === 3) { score = 155; // ### sections get medium score } else if (headingLevel === 4) { score = 152; // #### sections get lower score } matchType = `Exact Title Match (H${headingLevel})`; } else { // Main document title (# level) gets the highest score score = 165; matchType = 'Exact Title Match (Main)'; } } // 4. High word match ratio (most query words found in title) else if (wordMatchRatio >= 0.6 && wordMatchCount >= 2) { score = 140 + (wordMatchRatio * 20); // 140-160 range matchType = `High Word Match (${Math.round(wordMatchRatio * 100)}%)`; } // 5. Title contains full query phrase else if (titleContainsQuery && q.length > 5) { score = 135; matchType = 'Title Contains Query'; } // 6. Query contains title (searching for specific within general) else if (queryContainsTitle && doc.title.length > 5) { score = 130; matchType = 'Query Contains Title'; } // 7. Fall back to existing logic for other cases else if (controlNameMatch && controlName?.toLowerCase() === q.toLowerCase()) { score = 98; matchType = 'Exact Control Name Match'; } else if (keywordMatch && keywords.some((kw: string) => kw.toLowerCase() === q.toLowerCase())) { score = 96; matchType = 'Exact Keyword Match'; } else if (titleSimilarity > 80) { score = 95; matchType = 'High Title Similarity'; } else if (controlNameMatch) { score = 92; matchType = 'Control Name Match'; } else if (doc.title.toLowerCase().includes(q.toLowerCase())) { score = 90; matchType = 'Title Contains Query'; } else if (keywordMatch) { score = 87; matchType = 'Keyword Match'; } else if (descSimilarity > 70) { score = 85; matchType = 'High Description Similarity'; } else if (propertyMatch) { score = 82; matchType = 'Property/Event Match'; } else if (lib.id === '/openui5-api' && isControlMatch(doc.title, q)) { score = 80; matchType = 'UI5 Control Pattern Match'; } else if (doc.description.toLowerCase().includes(q.toLowerCase())) { score = 75; matchType = 'Description Contains Query'; } else if (titleSimilarity > 60) { score = 70; matchType = 'Moderate Title Similarity'; } else if (descSimilarity > 50) { score = 65; matchType = 'Moderate Description Similarity'; } else if (doc.title.toLowerCase().split(/[.\s_-]/).some(part => calculateSimilarity(part, q) > 70)) { score = 60; matchType = 'Partial Title Match'; } // Context-aware scoring boosts if (score > 0) { // UI5-specific boosts if (lib.id === '/openui5-api') { score += 5; // Base boost for API docs // Extra boost for UI5 control terms const ui5Terms = ['control', 'sap.m', 'sap.ui', 'sap.f', 'wizard', 'button', 'table']; if (ui5Terms.some(term => q.includes(term))) score += 8; } if (lib.id === '/openui5-samples') { // Boost samples for implementation queries if (q.includes('sample') || q.includes('example') || q.includes('demo')) score += 10; // Boost for UI5 control samples if (controlName && q.includes(controlName.toLowerCase())) score += 12; } if (lib.id === '/sapui5') { // Boost SAPUI5 docs for UI5-specific queries const ui5Queries = ['fiori', 'ui5', 'sapui5', 'control', 'binding', 'routing']; if (ui5Queries.some(term => q.includes(term))) score += 8; // Boost for conceptual queries if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 6; } // CAP-specific boosts if (lib.id === '/cap') { const capTerms = ['cds', 'cap', 'service', 'entity', 'annotation', 'aspect', 'odata', 'hana', 'temporal']; if (capTerms.some(term => q.includes(term))) score += 10; // Extra boost for CAP core concepts const coreCapTerms = ['cds', 'entity', 'service', 'aspect', 'temporal']; if (coreCapTerms.some(term => q.includes(term))) score += 5; // Boost for development guides if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8; } // wdi5-specific boosts if (lib.id === '/wdi5') { const wdi5Terms = ['wdi5', 'test', 'testing', 'e2e', 'browser', 'selector', 'webdriver', 'wdio', 'pageobject', 'fe-testlib']; if (wdi5Terms.some(term => q.includes(term))) score += 15; // Extra boost for testing concepts const testingTerms = ['test', 'testing', 'assertion', 'automation', 'locator', 'matcher']; if (testingTerms.some(term => q.includes(term))) score += 10; // Boost for UI5 testing specific terms const ui5TestingTerms = ['sap.ui.test', 'ui5 test', 'control selector', 'byId', 'byProperty']; if (ui5TestingTerms.some(term => q.includes(term))) score += 12; } // UI5 Web Components specific boosts if (lib.id === '/ui5-webcomponents') { const ui5WebComponentsTerms = ['component', 'web', 'web-component', 'web-components']; if (ui5WebComponentsTerms.some(term => q.includes(term))) score += 15; // Boost for conceptual queries if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 6; } // SAP Cloud SDK specific boosts if (lib.id === '/cloud-sdk-js' || lib.id === '/cloud-sdk-ai-js' || lib.id === '/cloud-sdk-java' || lib.id === '/cloud-sdk-ai-java') { const sapCloudSdkTerms = ['cloud sdk', 'sdk', 'cloud', 'sdk for ai', 'ai']; if (sapCloudSdkTerms.some(term => q.includes(term))) score += 15; // Boost for development guides if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8; } // UI5 Tooling specific boosts if (lib.id === '/ui5-tooling') { const ui5ToolingTerms = ['ui5', '@ui5/', 'tooling', 'cli', 'tasks', 'middleware', 'shims']; if (ui5ToolingTerms.some(term => q.includes(term))) score += 15; // Boost for development guides if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8; } // Cloud MTA Build Tool specific boosts if (lib.id === '/cloud-mta-build-tool') { const cloudMtaBuildToolTerms = ['mta', 'mtar', 'multitarget', 'build', 'cloud mta build tool']; if (cloudMtaBuildToolTerms.some(term => q.includes(term))) score += 15; // Boost for development guides if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8; } // Apply context-aware penalties to reduce off-topic results score = applyContextPenalties(score, lib.id, queryContext, q); // Intent-weighted library priors (soft bias) const priors = computeLibraryPriors(queryContext); const prior = priors[lib.id] ?? 0; const alpha = 0.25; // strength of prior influence (moderate) let finalScore = score * (1 + alpha * prior); // Topic-specific additive bonus: error-handling intent for SDK/AI docs const oq = originalQuery.toLowerCase(); const errorIntent = oq.includes('error handling') || oq.includes('error information') || (/\berror\b/.test(oq) && (oq.includes('sdk') || oq.includes('ai'))); if (errorIntent) { const titleL = (doc.title || '').toLowerCase(); const relL = String((doc as any).relFile || '').toLowerCase(); if (lib.id.startsWith('/cloud-sdk') && (titleL.includes('error') || relL.includes('error'))) { finalScore += 15; } } allMatches.push({ score: Math.max(0, finalScore), // floor at 0, no hard cap to preserve ordering libraryId: lib.id, libraryName: lib.name, docId: doc.id, docTitle: doc.title, docDescription: doc.description, matchType, snippetCount: doc.snippetCount, source: 'docs', titleSimilarity, descSimilarity }); } } } } // If still no results, try comprehensive fuzzy search if (allMatches.length === 0) { const originalQuery = query.toLowerCase(); const queryParts = originalQuery.split(/[\s_-]+/).filter(part => part.length > 2); for (const lib of Object.values(index)) { for (const doc of lib.docs) { // If we're using FTS filtering, only process documents that are in the candidate set if (usedFTS && !candidateDocIds.has(doc.id)) { continue; } let maxScore = 0; let bestMatchType = 'Fuzzy Search'; // Try fuzzy matching against title parts const titleParts = doc.title.toLowerCase().split(/[\s._-]+/); for (const titlePart of titleParts) { for (const queryPart of queryParts) { const similarity = calculateSimilarity(titlePart, queryPart); if (similarity > maxScore && similarity > 50) { maxScore = similarity * 0.8; // Reduce score for fuzzy matches bestMatchType = 'Fuzzy Title Match'; } } } // Try fuzzy matching against description parts const descParts = doc.description.toLowerCase().split(/[\s._-]+/); for (const descPart of descParts) { if (descPart.length > 3) { // Only check meaningful words for (const queryPart of queryParts) { const similarity = calculateSimilarity(descPart, queryPart); if (similarity > maxScore && similarity > 50) { maxScore = similarity * 0.6; // Further reduce for description matches bestMatchType = 'Fuzzy Description Match'; } } } } if (maxScore > 30) { // Lower threshold for fuzzy results allMatches.push({ score: maxScore, libraryId: lib.id, libraryName: lib.name, docId: doc.id, docTitle: doc.title, docDescription: doc.description, matchType: bestMatchType, snippetCount: doc.snippetCount, source: 'docs' }); } } } } // Sort all results by relevance score (highest first), then by title function sortByScore(a: any, b: any) { if (b.score !== a.score) return b.score - a.score; return a.docTitle.localeCompare(b.docTitle); } // Deduplicate by docId (keep highest-score variant) const byId = new Map<string, any>(); for (const m of allMatches) { const prev = byId.get(m.docId); if (!prev || m.score > prev.score) byId.set(m.docId, m); } allMatches = Array.from(byId.values()); allMatches.sort(sortByScore); // Take top results regardless of library, but ensure diversity const topResults = []; const seenLibraries = new Set(); const maxPerLibrary = queryContext === 'MIXED' ? 3 : 5; // More diversity for mixed queries for (const result of allMatches) { if (topResults.length >= 20) break; // Limit total results const libraryCount = topResults.filter(r => r.libraryId === result.libraryId).length; if (libraryCount < maxPerLibrary) { topResults.push(result); seenLibraries.add(result.libraryId); } } // Group results by library for presentation (but maintain score order) const apiDocs = topResults.filter(r => r.libraryId === '/openui5-api'); const samples = topResults.filter(r => r.libraryId === '/openui5-samples'); const guides = topResults.filter(r => r.libraryId === '/sapui5' || r.libraryId === '/cap' || r.libraryId === '/wdi5' || r.libraryId === '/ui5-webcomponents' || r.libraryId === '/cloud-sdk-js' || r.libraryId === '/cloud-sdk-ai-js' || r.libraryId === '/cloud-sdk-java' || r.libraryId === '/cloud-sdk-ai-java' || r.libraryId === '/ui5-tooling' || r.libraryId === '/cloud-mta-build-tool'); if (!topResults.length) { // User feedback loop: suggest alternatives let suggestion = "No documentation found for '" + query + "'. "; if (fileContent) { suggestion += "Try searching for: " + extractControlsFromContent(fileContent).join(", ") + ". "; } suggestion += "Try terms like 'button', 'table', 'wizard', 'routing', 'annotation', or check for typos."; return { results: [], error: suggestion }; } // Group results for presentation with context awareness const contextEmojis = getAllContextEmojis(); // Add FTS info to response for transparency const ftsInfo = usedFTS ? ` (๐Ÿš€ FTS-filtered from ${candidateDocIds.size} candidates)` : ' (๐Ÿ” Full search)'; let response = `Found ${topResults.length} results for '${query}' ${getContextEmoji(queryContext)} **${queryContext} Context**${ftsInfo}:\n\n`; // Show results in score order, grouped by type if (guides.length > 0) { const capGuides = guides.filter(r => r.libraryId === '/cap'); const wdi5Guides = guides.filter(r => r.libraryId === '/wdi5'); const sapui5Guides = guides.filter(r => r.libraryId === '/sapui5'); const ui5WebComponentsGuides = guides.filter(r => r.libraryId === '/ui5-webcomponents'); const sapCloudSdkGuides = guides.filter(r => r.libraryId === '/cloud-sdk-js' || r.libraryId === '/cloud-sdk-ai-js' || r.libraryId === '/cloud-sdk-java' || r.libraryId === '/cloud-sdk-ai-java'); const ui5ToolingGuides = guides.filter(r => r.libraryId === '/ui5-tooling'); const cloudMtaBuildToolGuides = guides.filter(r => r.libraryId === '/cloud-mta-build-tool'); if (capGuides.length > 0) { response += `๐Ÿ—๏ธ **CAP Documentation:**\n`; for (const r of capGuides) { response += formatSearchResultEntry(r, queryContext); } } if (wdi5Guides.length > 0) { response += `๐Ÿงช **wdi5 Documentation:**\n`; for (const r of wdi5Guides) { response += formatSearchResultEntry(r, queryContext); } } if (sapui5Guides.length > 0) { response += `๐Ÿ“– **SAPUI5 Guides:**\n`; for (const r of sapui5Guides) { response += formatSearchResultEntry(r, queryContext); } } if (ui5WebComponentsGuides.length > 0) { response += `๐Ÿ•น๏ธ **UI5 Web Components Guides:**\n`; for (const r of ui5WebComponentsGuides) { response += formatSearchResultEntry(r, queryContext); } } if (ui5ToolingGuides.length > 0) { response += `๐Ÿ”ง **UI5 Tooling Guides:**\n`; for (const r of ui5ToolingGuides) { response += formatSearchResultEntry(r, queryContext); } } if (sapCloudSdkGuides.length > 0) { response += `๐ŸŒ **SAP Cloud SDK Guides:**\n`; for (const r of sapCloudSdkGuides) { response += formatSearchResultEntry(r, queryContext); } } if (cloudMtaBuildToolGuides.length > 0) { response += `๐Ÿšข **Cloud MTA Build Tool Guides:**\n`; for (const r of cloudMtaBuildToolGuides) { response += formatSearchResultEntry(r, queryContext); } } } if (apiDocs.length > 0) { response += `๐Ÿ”น **UI5 API Documentation:**\n`; for (const r of apiDocs.slice(0, 8)) { response += formatSearchResultEntry(r, queryContext); } } if (samples.length > 0) { response += `๐Ÿ”ธ **UI5 Samples:**\n`; for (const r of samples.slice(0, 8)) { response += formatSearchResultEntry(r, queryContext); } } response += `๐Ÿ’ก **Context**: ${queryContext} query detected. Scores reflect relevance to this context.\n`; response += `๐Ÿ” **Tried queries**: ${triedQueries.slice(0, 3).join(", ")}${triedQueries.length > 3 ? '...' : ''}`; return { results: topResults.map((r, index) => { const { libraryId, topic } = parseDocumentId(r.docId); return { library_id: libraryId, topic: topic, id: r.docId, title: r.docTitle, url: r.url || `#${r.docId}`, snippet: r.docDescription, score: r.score, metadata: { source: 'sap-docs', library: libraryId, rank: index + 1, context: queryContext } }; }) }; } export async function fetchLibraryDocumentation( libraryIdOrDocId: string, topic = "" ): Promise<string | null> { // Check if this is a community post ID if (libraryIdOrDocId.startsWith('community-')) { return await getCommunityPost(libraryIdOrDocId); } const index = await loadIndex(); // Check if this is a specific document ID const allDocs: Array<{lib: any, doc: any}> = []; for (const lib of Object.values(index)) { for (const doc of lib.docs) { allDocs.push({ lib, doc }); // Try exact match first (for section documents with fragments) if (doc.id === libraryIdOrDocId) { const sourcePath = getSourcePath(lib.id); if (!sourcePath) { throw new Error(`Unknown library ID: ${lib.id}`); } const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); const content = await fs.readFile(abs, "utf8"); // For JavaScript API files, format the content for better readability if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') { return formatJSDocContent(content, doc.title || ''); } // For sample files, format them appropriately else if (lib.id === '/openui5-samples') { return formatSampleContent(content, doc.relFile, doc.title || ''); } // For documented libraries, add URL context else if (getDocUrlConfig(lib.id)) { const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content); const libName = lib.id.replace('/', '').toUpperCase(); return `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; } else { return content; } } } } // If no exact match found and the ID contains a fragment, try stripping the fragment // This handles cases where fragments are used for navigation but don't exist as separate documents if (libraryIdOrDocId.includes('#')) { const baseId = libraryIdOrDocId.split('#')[0]; // Try to find a document with the base ID (without fragment) for (const lib of Object.values(index)) { for (const doc of lib.docs) { if (doc.id === baseId) { const sourcePath = getSourcePath(lib.id); if (!sourcePath) { throw new Error(`Unknown library ID: ${lib.id}`); } const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); const content = await fs.readFile(abs, "utf8"); // For JavaScript API files, format the content for better readability if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') { return formatJSDocContent(content, doc.title || ''); } // For sample files, format them appropriately else if (lib.id === '/openui5-samples') { return formatSampleContent(content, doc.relFile, doc.title || ''); } // For documented libraries, add URL context else if (getDocUrlConfig(lib.id)) { const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content); const libName = lib.id.replace('/', '').toUpperCase(); return `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; } else { return content; } } } } // Try library-level lookup with base ID const baseLib = index[baseId]; if (baseLib) { const term = topic.toLowerCase(); const targets = term ? baseLib.docs.filter( (d) => d.title.toLowerCase().includes(term) || d.description.toLowerCase().includes(term) ) : baseLib.docs; if (!targets.length) return `No topic "${topic}" found inside ${baseId}.`; const parts: string[] = []; for (const doc of targets) { const sourcePath = getSourcePath(baseLib.id); if (!sourcePath) { throw new Error(`Unknown library ID: ${baseLib.id}`); } const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); const content = await fs.readFile(abs, "utf8"); // For JavaScript API files, format the content for better readability if (doc.relFile && doc.relFile.endsWith('.js') && baseLib.id === '/openui5-api') { const formattedContent = formatJSDocContent(content, doc.title || ''); parts.push(formattedContent); } // For sample files, format them appropriately else if (baseLib.id === '/openui5-samples') { const formattedContent = formatSampleContent(content, doc.relFile, doc.title || ''); parts.push(formattedContent); } // For documented libraries, add URL context else if (getDocUrlConfig(baseLib.id)) { const documentationUrl = generateDocumentationUrl(baseLib.id, doc.relFile, content); const libName = baseLib.id.replace('/', '').toUpperCase(); const formattedContent = `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; parts.push(formattedContent); } else { parts.push(content); } } return parts.join("\n\n---\n\n"); } } // If not a specific document ID, treat as library ID with optional topic const lib = index[libraryIdOrDocId]; if (!lib) return null; // If topic is provided, first try to construct the full document ID if (topic) { const fullDocId = `${libraryIdOrDocId}/${topic}`; // Try to find exact document match first for (const doc of lib.docs) { if (doc.id === fullDocId) { const sourcePath = getSourcePath(lib.id); if (!sourcePath) { throw new Error(`Unknown library ID: ${lib.id}`); } const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); const content = await fs.readFile(abs, "utf8"); // Format the content appropriately based on library type if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') { return formatJSDocContent(content, doc.title || ''); } else if (lib.id === '/openui5-samples') { return formatSampleContent(content, doc.relFile, doc.title || ''); } else if (getDocUrlConfig(lib.id)) { const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content); const libName = lib.id.replace('/', '').toUpperCase(); return `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; } else { return content; } } } // If exact match not found, fall back to topic keyword search const term = topic.toLowerCase(); const targets = lib.docs.filter( (d) => d.title.toLowerCase().includes(term) || d.description.toLowerCase().includes(term) ); if (targets.length > 0) { // Process the filtered documents const parts: string[] = []; for (const doc of targets) { const sourcePath = getSourcePath(lib.id); if (!sourcePath) { throw new Error(`Unknown library ID: ${lib.id}`); } const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); const content = await fs.readFile(abs, "utf8"); if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') { const formattedContent = formatJSDocContent(content, doc.title || ''); parts.push(formattedContent); } else if (lib.id === '/openui5-samples') { const formattedContent = formatSampleContent(content, doc.relFile, doc.title || ''); parts.push(formattedContent); } else if (getDocUrlConfig(lib.id)) { const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content); const libName = lib.id.replace('/', '').toUpperCase(); const formattedContent = `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; parts.push(formattedContent); } else { parts.push(content); } } return parts.join("\n\n---\n\n"); } return `No topic "${topic}" found inside ${libraryIdOrDocId}.`; } // No topic provided, return all library documents const targets = lib.docs; if (!targets.length) return `No documents found inside ${libraryIdOrDocId}.`; const parts: string[] = []; for (const doc of targets) { const sourcePath = getSourcePath(lib.id); if (!sourcePath) { throw new Error(`Unknown library ID: ${lib.id}`); } const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); const content = await fs.readFile(abs, "utf8"); // For JavaScript API files, format the content for better readability if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') { const formattedContent = formatJSDocContent(content, doc.title || ''); parts.push(formattedContent); } // For sample files, format them appropriately else if (lib.id === '/openui5-samples') { const formattedContent = formatSampleContent(content, doc.relFile, doc.title || ''); parts.push(formattedContent); } // For documented libraries, add URL context else if (getDocUrlConfig(lib.id)) { const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content); const libName = lib.id.replace('/', '').toUpperCase(); const formattedContent = `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; parts.push(formattedContent); } else { parts.push(content); } } return parts.join("\n\n---\n\n"); } // Resource support for MCP export async function listDocumentationResources() { const index = await loadIndex(); const resources: Array<{ uri: string; name: string; description?: string; mimeType?: string; }> = []; // Add library overview resources for (const lib of Object.values(index)) { resources.push({ uri: `sap-docs://${lib.id}`, name: `${lib.name} Documentation Overview`, description: lib.description, mimeType: "text/markdown" }); // Add individual document resources for (const doc of lib.docs) { resources.push({ uri: `sap-docs://${lib.id}/${encodeURIComponent(doc.relFile)}`, name: doc.title, description: `${doc.description} (${doc.snippetCount} code snippets)`, mimeType: "text/markdown" }); } } // Add SAP Community as a searchable resource resources.push({ uri: "sap-docs:///community", name: "SAP Community Posts", description: "Real-time access to SAP Community blog posts, discussions, and solutions. Search for topics to find community insights and practical solutions.", mimeType: "text/markdown" }); return resources; } export async function readDocumentationResource(uri: string) { const index = await loadIndex(); // Handle community overview if (uri === "sap-docs:///community") { const overview = [ `# SAP Community`, ``, `Real-time access to SAP Community blog posts, discussions, and solutions.`, ``, `## How to Use`, ``, `1. Use the search function to find community posts on specific topics`, `2. Search for terms like "wizard", "button", "authentication", "deployment", etc.`, `3. Get access to real-world solutions and community best practices`, `4. Use specific community post IDs (e.g., "community-12345") to retrieve full content`, ``, `## What You'll Find`, ``, `- **Blog Posts**: Technical tutorials and deep-dives`, `- **Solutions**: Answers to common problems`, `- **Best Practices**: Community-tested approaches`, `- **Code Examples**: Real-world implementations`, ``, `Community content includes engagement data (kudos) when available and follows SAP Community's Best Match ranking.`, ``, `*Note: Community content is fetched in real-time from the SAP Community API.*` ].join('\n'); return { contents: [{ uri, mimeType: "text/markdown", text: overview }] }; } // Parse URI: sap-docs://[libraryId]/[optional-file-path] // Note: libraryId starts with '/', so we may have sap-docs:///cap/... (3 slashes) const match = uri.match(/^sap-docs:\/\/\/?([^\/]+)(?:\/(.+))?$/); if (!match) { throw new Error(`Invalid resource URI: ${uri}`); } const [, libIdPart, encodedFilePath] = match; // Library ID should have leading slash const libraryId = libIdPart.startsWith('/') ? libIdPart : `/${libIdPart}`; const lib = index[libraryId]; if (!lib) { throw new Error(`Library not found: ${libraryId}`); } // If no file path, return library overview if (!encodedFilePath) { const overview = [ `# ${lib.name}`, ``, `${lib.description}`, ``, `## Available Documents`, ``, ...lib.docs.map(doc => `- **${doc.title}**: ${doc.description} (${doc.snippetCount} code snippets)` ), ``, `Total documents: ${lib.docs.length}`, `Total code snippets: ${lib.docs.reduce((sum, doc) => sum + doc.snippetCount, 0)}` ].join('\n'); return { contents: [{ uri, mimeType: "text/markdown", text: overview }] }; } // Find and return specific document const filePath = decodeURIComponent(encodedFilePath); const doc = lib.docs.find(d => d.relFile === filePath); if (!doc) { throw new Error(`Document not found: ${filePath}`); } const sourcePath = getSourcePath(libraryId); if (!sourcePath) { throw new Error(`Unknown library ID: ${libraryId}`); } const absPath = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile); try { const content = await fs.readFile(absPath, "utf8"); // Format files for better readability let formattedContent = content; if (doc.relFile && doc.relFile.endsWith('.js') && libraryId === '/openui5-api') { formattedContent = formatJSDocContent(content, doc.title || ''); } else if (libraryId === '/openui5-samples') { formattedContent = formatSampleContent(content, doc.relFile, doc.title || ''); } else if (getDocUrlConfig(libraryId)) { const documentationUrl = generateDocumentationUrl(libraryId, doc.relFile, content); const libName = libraryId.replace('/', '').toUpperCase(); formattedContent = `**Source:** ${libName} Documentation **URL:** ${documentationUrl || 'Documentation URL not available'} **File:** ${doc.relFile} --- ${content} --- *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`; } return { contents: [{ uri, mimeType: "text/markdown", text: formattedContent }] }; } catch (error) { throw new Error(`Failed to read document: ${error}`); } } // Export the community search function for use as a separate tool export async function searchCommunity(query: string): Promise<SearchResponse> { try { // Use the convenience function to search and get top 3 posts with full content const result = await searchAndGetTopPosts(query, 3, { includeBlogs: true, userAgent: 'SAP-Docs-MCP/1.0' }); if (result.search.length === 0) { return { results: [], error: `No SAP Community posts found for "${query}". Try different keywords or check your connection.` }; } // Format the results with full post content let response = `Found ${result.search.length} SAP Community posts for "${query}" with full content:\n\n`; response += `๐ŸŒ **SAP Community Posts with Full Content:**\n\n`; for (const searchResult of result.search) { const postContent = result.posts[searchResult.postId || '']; if (postContent) { // Add the full post content response += postContent + '\n\n'; response += `---\n\n`; } else { // Fallback to search result info if full content not available const postDate = searchResult.published || 'Unknown'; response += `### **${searchResult.title}**\n`; response += `**Posted:** ${postDate}\n`; response += `**Description:** ${searchResult.snippet || 'No description available'}\n`; response += `**URL:** ${searchResult.url}\n`; response += `**ID:** \`community-${searchResult.postId}\`\n\n`; response += `---\n\n`; } } response += `๐Ÿ’ก **Note:** These results include the full content from ${Object.keys(result.posts).length} SAP Community posts, representing real-world developer experiences and solutions.`; return { results: result.search.map((searchResult, index) => ({ library_id: `community-${searchResult.postId || index}`, topic: '', id: `community-${searchResult.postId || index}`, title: searchResult.title, url: searchResult.url, snippet: searchResult.snippet || '', score: 0, metadata: { source: 'community', postTime: searchResult.published, author: searchResult.author, likes: searchResult.likes, tags: searchResult.tags, rank: index + 1 } })) }; } catch (error: any) { console.error("Error searching SAP Community:", error); return { results: [], error: `Error searching SAP Community: ${error?.message || 'Unknown error'}. Please try again later.` }; } }

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/marianfoo/mcp-sap-docs'

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