UNPKG

@dnb/eufemia

Version:

DNB Eufemia Design System UI Library

640 lines (639 loc) 26.9 kB
#!/usr/bin/env node "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createDocsTools = createDocsTools; var _replaceAll = _interopRequireDefault(require("core-js-pure/stable/instance/replace-all.js")); var _push = _interopRequireDefault(require("core-js-pure/stable/instance/push.js")); var _parse = _interopRequireDefault(require("core-js-pure/stable/json/parse.js")); var _promises = _interopRequireDefault(require("node:fs/promises")); var _nodePath = _interopRequireDefault(require("node:path")); var _nodeProcess = _interopRequireDefault(require("node:process")); var _zod = require("zod"); var _mcp = require("@modelcontextprotocol/sdk/server/mcp.js"); var _stdio = require("@modelcontextprotocol/sdk/server/stdio.js"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } function logErr(...args) { console.error(...args); } function normalizeRelPath(p) { var _context; return (0, _replaceAll.default)(_context = String(p !== null && p !== void 0 ? p : '').replace(/^\/+/, '')).call(_context, '\\', '/'); } function resolveInside(rootAbs, userPath) { const cleaned = normalizeRelPath(userPath); const abs = _nodePath.default.resolve(rootAbs, cleaned); const rel = _nodePath.default.relative(rootAbs, abs); if (rel.startsWith('..') || _nodePath.default.isAbsolute(rel)) { throw new Error(`Path escapes docs root: ${userPath}`); } const relNorm = (0, _replaceAll.default)(rel).call(rel, _nodePath.default.sep, '/'); return { abs, rel: relNorm, relWithLeadingSlash: '/' + relNorm }; } async function statSafe(p) { try { return await _promises.default.stat(p); } catch { return null; } } async function fileExists(p) { try { await _promises.default.access(p); return true; } catch { return false; } } async function readTextFile(absPath) { const buf = await _promises.default.readFile(absPath); return buf.toString('utf8'); } async function listDirSafe(absDir, max = 60) { try { const items = await _promises.default.readdir(absDir); return items.slice(0, max); } catch { return []; } } async function listMarkdownFiles(rootAbs) { const out = []; const stack = ['']; while (stack.length > 0) { var _stack$pop; const relDir = (_stack$pop = stack.pop()) !== null && _stack$pop !== void 0 ? _stack$pop : ''; const absDir = _nodePath.default.join(rootAbs, relDir); let entries; try { entries = await _promises.default.readdir(absDir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.name.startsWith('.')) { continue; } const relPath = _nodePath.default.join(relDir, entry.name); if (entry.isDirectory()) { if (entry.name === 'node_modules') { continue; } (0, _push.default)(stack).call(stack, relPath); continue; } if (entry.isFile() && (entry.name.toLowerCase().endsWith('.md') || entry.name.toLowerCase().endsWith('.mdx'))) { (0, _push.default)(out).call(out, relPath); } } } return out; } function computeDocsRoot() { if (_nodeProcess.default.env.EUFEMIA_DOCS_ROOT) { return _nodePath.default.resolve(_nodeProcess.default.env.EUFEMIA_DOCS_ROOT); } const entryPath = _nodeProcess.default.argv[1] ? _nodePath.default.resolve(_nodeProcess.default.argv[1]) : ''; const entryName = entryPath ? _nodePath.default.basename(entryPath) : ''; if (entryName) { return _nodePath.default.resolve(_nodePath.default.dirname(entryPath), '../docs'); } return _nodePath.default.resolve(_nodeProcess.default.cwd(), 'docs'); } function extractFrontmatterLinks(markdown) { const m = String(markdown !== null && markdown !== void 0 ? markdown : '').match(/^---\s*\n([\s\S]*?)\n---\s*\n/); if (!m) { return null; } const fm = m[1]; const lines = fm.split('\n').map(l => l.trim()); const readLine = key => { const line = lines.find(l => l.startsWith(`${key}:`)); if (!line) { return null; } const val = line.split(':').slice(1).join(':').trim(); return val.replace(/^['"]|['"]$/g, ''); }; return { doc: readLine('doc'), properties: readLine('properties'), events: readLine('events') }; } function normalizeName(name) { return String(name !== null && name !== void 0 ? name : '').trim().toLowerCase(); } function conventionalDocPath(name) { if (name.includes('.')) { const parts = name.split('.'); const prefix = parts[0]; const componentName = parts.slice(1).join('.'); const capitalizedName = componentName.charAt(0).toUpperCase() + componentName.slice(1); if (prefix.toLowerCase() === 'field') { return [`/uilib/extensions/forms/feature-fields/${capitalizedName}.mdx`, `/uilib/extensions/forms/feature-fields/${capitalizedName}.md`, `/uilib/extensions/forms/base-fields/${capitalizedName}.mdx`, `/uilib/extensions/forms/base-fields/${capitalizedName}.md`]; } else if (prefix.toLowerCase() === 'value') { return [`/uilib/extensions/forms/Value/${capitalizedName}.mdx`, `/uilib/extensions/forms/Value/${capitalizedName}.md`]; } else if (prefix.toLowerCase() === 'form') { return [`/uilib/extensions/forms/Form/${capitalizedName}.mdx`, `/uilib/extensions/forms/Form/${capitalizedName}.md`]; } return [`/uilib/extensions/forms/${prefix}/${capitalizedName}.mdx`, `/uilib/extensions/forms/${prefix}/${capitalizedName}.md`]; } return [`/uilib/components/${normalizeName(name)}.md`]; } function extractJsonBlocks(markdown) { const blocks = []; const regex = /```json\s*([\s\S]*?)```/gi; let match; while (match = regex.exec(markdown)) { var _match$; const raw = (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim(); if (!raw) { continue; } try { (0, _push.default)(blocks).call(blocks, (0, _parse.default)(raw)); } catch {} } return blocks; } function makeTextResult(text) { return { content: [{ type: 'text', text }] }; } function createDocsContext(docsRoot) { const llmMdAbs = _nodePath.default.resolve(docsRoot, 'llm.md'); let cachedMdFiles = null; let cachedMdFilesAt = 0; const MD_FILES_TTL_MS = 30_000; async function getMarkdownFilesCached(prefix) { const now = Date.now(); if (!cachedMdFiles || now - cachedMdFilesAt > MD_FILES_TTL_MS) { const files = await listMarkdownFiles(docsRoot); cachedMdFiles = files.map(f => '/' + (0, _replaceAll.default)(f).call(f, _nodePath.default.sep, '/')); cachedMdFilesAt = now; } if (!prefix) { return cachedMdFiles; } const pfx = '/' + normalizeRelPath(prefix).replace(/\/?$/, '/'); return cachedMdFiles.filter(p => p.startsWith(pfx)); } async function resolveComponentPaths(name) { var _await$statSafe, _await$statSafe2, _await$statSafe3; let doc = null; let properties = null; let events = null; const slug = null; const possiblePaths = conventionalDocPath(name); for (const candidatePath of possiblePaths) { try { const candidateAbs = resolveInside(docsRoot, candidatePath).abs; const st = await statSafe(candidateAbs); if (st !== null && st !== void 0 && st.isFile()) { doc = candidatePath; break; } if (st !== null && st !== void 0 && st.isDirectory()) { const tryMd = candidatePath.replace(/\.(mdx?)?$/, '') + '.md'; const tryMdx = candidatePath.replace(/\.(mdx?)?$/, '') + '.mdx'; for (const tryPath of [tryMd, tryMdx]) { const tryAbs = resolveInside(docsRoot, tryPath).abs; const trySt = await statSafe(tryAbs); if (trySt !== null && trySt !== void 0 && trySt.isFile()) { doc = tryPath; break; } } if (doc) break; } } catch { continue; } } if (!doc && possiblePaths.length > 0) { doc = possiblePaths[0]; } if (!properties || !events) { try { const docAbs = resolveInside(docsRoot, doc).abs; const st = await statSafe(docAbs); if (st !== null && st !== void 0 && st.isFile()) { const mdText = await readTextFile(docAbs); const links = extractFrontmatterLinks(mdText); if (links !== null && links !== void 0 && links.properties) { properties = links.properties; } if (links !== null && links !== void 0 && links.events) { events = links.events; } } } catch {} } if (!properties) { properties = doc; } if (!events) { events = doc; } const docExists = Boolean((_await$statSafe = await statSafe(resolveInside(docsRoot, doc).abs)) === null || _await$statSafe === void 0 ? void 0 : _await$statSafe.isFile()); const propertiesExists = Boolean((_await$statSafe2 = await statSafe(resolveInside(docsRoot, properties).abs)) === null || _await$statSafe2 === void 0 ? void 0 : _await$statSafe2.isFile()); const eventsExists = Boolean((_await$statSafe3 = await statSafe(resolveInside(docsRoot, events).abs)) === null || _await$statSafe3 === void 0 ? void 0 : _await$statSafe3.isFile()); return { name, doc, docExists, properties, propertiesExists, events, eventsExists, slug, fromIndex: false }; } async function searchInMarkdown(query, limit, prefix, opts = {}) { var _opts$concurrency, _opts$timeoutMs; const q = String(query !== null && query !== void 0 ? query : '').trim(); if (q.length < 2) { return []; } const queryWords = q.toLowerCase().split(/\s+/).filter(w => w.length > 0); if (queryWords.length === 0) { return []; } const files = await getMarkdownFilesCached(prefix); const concurrency = Math.max(1, Math.min(32, Number((_opts$concurrency = opts.concurrency) !== null && _opts$concurrency !== void 0 ? _opts$concurrency : 12))); const timeoutMs = Math.max(200, Math.min(15_000, Number((_opts$timeoutMs = opts.timeoutMs) !== null && _opts$timeoutMs !== void 0 ? _opts$timeoutMs : 2000))); const deadline = Date.now() + timeoutMs; const hits = []; let cursor = 0; let stopped = false; async function worker() { while (!stopped) { if (Date.now() > deadline) { stopped = true; return; } const i = cursor++; if (i >= files.length) { return; } const relPath = files[i]; const { abs } = resolveInside(docsRoot, relPath); let text; try { text = await readTextFile(abs); } catch { continue; } const lower = text.toLowerCase(); if (queryWords.length === 1) { const q = queryWords[0]; const idx = lower.indexOf(q); if (idx === -1) { continue; } const occurrences = lower.split(q).length - 1; const score = Math.max(1, 1000 - idx) + occurrences * 25; const start = Math.max(0, idx - 80); const end = Math.min(text.length, idx + q.length + 220); const snippet = text.slice(start, end).replace(/\s+/g, ' ').trim(); (0, _push.default)(hits).call(hits, { path: relPath, score, occurrences, snippet }); } else { const wordMatches = []; let allWordsFound = true; for (const word of queryWords) { const indices = []; let searchIdx = 0; while (true) { const idx = lower.indexOf(word, searchIdx); if (idx === -1) { break; } (0, _push.default)(indices).call(indices, idx); searchIdx = idx + 1; } if (indices.length === 0) { allWordsFound = false; break; } (0, _push.default)(wordMatches).call(wordMatches, { word, indices }); } if (!allWordsFound) { continue; } const firstMatchIdx = Math.min(...wordMatches.map(m => m.indices[0])); const totalOccurrences = wordMatches.reduce((sum, m) => sum + m.indices.length, 0); let proximityScore = 0; if (wordMatches.length > 1) { const allIndices = wordMatches.flatMap(m => m.indices.map(idx => ({ word: m.word, idx }))); allIndices.sort((a, b) => a.idx - b.idx); const wordSet = new Set(queryWords); let minSpan = Infinity; for (let i = 0; i < allIndices.length; i++) { const foundWords = new Set(); for (let j = i; j < allIndices.length; j++) { foundWords.add(allIndices[j].word); if (foundWords.size === wordSet.size) { const span = allIndices[j].idx - allIndices[i].idx; minSpan = Math.min(minSpan, span); break; } } } proximityScore = minSpan < Infinity ? 1000 / (1 + minSpan / 10) : 0; } const score = Math.max(1, 1000 - firstMatchIdx) + totalOccurrences * 25 + proximityScore; const snippetStart = Math.max(0, firstMatchIdx - 80); const snippetEnd = Math.min(text.length, firstMatchIdx + queryWords.join(' ').length + 220); const snippet = text.slice(snippetStart, snippetEnd).replace(/\s+/g, ' ').trim(); (0, _push.default)(hits).call(hits, { path: relPath, score, occurrences: totalOccurrences, snippet }); } if (hits.length >= limit * 3) { stopped = true; return; } } } await Promise.all(Array.from({ length: concurrency }, () => worker())); hits.sort((a, b) => b.score - a.score); return hits.slice(0, limit); } return { docsRoot, llmMdAbs, getMarkdownFilesCached, resolveComponentPaths, searchInMarkdown }; } const EmptyInput = _zod.z.object({}); const DocsReadInput = _zod.z.object({ path: _zod.z.string().min(1).describe('Path relative to docs root (e.g. /uilib/components/button.md)') }); const DocsSearchInput = _zod.z.object({ query: _zod.z.any().describe('Search query (string recommended).'), limit: _zod.z.number().int().min(1).max(50).default(10), prefix: _zod.z.string().optional().describe('Optional prefix filter (e.g. /uilib/components/)') }); const DocsListInput = _zod.z.object({ prefix: _zod.z.string().optional().describe('Optional prefix filter (e.g. /uilib/components/)'), limit: _zod.z.number().int().min(1).max(500).default(200) }); const ComponentNameInput = _zod.z.object({ name: _zod.z.string().min(1).describe("The component name (e.g. 'Button', 'Dropdown', 'Input')") }); function createDocsTools(options = {}) { var _options$docsRoot; const docsRoot = (_options$docsRoot = options.docsRoot) !== null && _options$docsRoot !== void 0 ? _options$docsRoot : computeDocsRoot(); const context = createDocsContext(docsRoot); const docsEntry = async _input => { if (!(await fileExists(context.llmMdAbs))) { return makeTextResult('llm.md not found in docs root.'); } return makeTextResult(await readTextFile(context.llmMdAbs)); }; const docsIndex = async _input => { const files = await context.getMarkdownFilesCached(); return makeTextResult(JSON.stringify(files, null, 2)); }; const docsList = async ({ prefix, limit }) => { const files = await context.getMarkdownFilesCached(prefix); return makeTextResult(JSON.stringify(files.slice(0, limit), null, 2)); }; const docsRead = async ({ path: userPath }) => { let resolved; try { resolved = resolveInside(context.docsRoot, userPath); } catch (e) { var _e$message; return makeTextResult(`Invalid path: ${String((_e$message = e === null || e === void 0 ? void 0 : e.message) !== null && _e$message !== void 0 ? _e$message : e)}`); } const { abs, relWithLeadingSlash } = resolved; const st = await statSafe(abs); if (!st) { const base = relWithLeadingSlash.replace(/\/+$/, ''); const mdGuess = `${base}.md`; let mdExists = false; try { var _await$statSafe4; mdExists = Boolean((_await$statSafe4 = await statSafe(resolveInside(context.docsRoot, mdGuess).abs)) === null || _await$statSafe4 === void 0 ? void 0 : _await$statSafe4.isFile()); } catch { mdExists = false; } return makeTextResult(JSON.stringify({ error: 'ENOENT', message: 'Not found.', path: relWithLeadingSlash, suggestions: mdExists ? [mdGuess] : [] }, null, 2)); } if (st.isDirectory()) { const children = await listDirSafe(abs, 60); const base = relWithLeadingSlash.replace(/\/+$/, ''); const guessList = [`${base}.md`, `${base}/README.md`, `${base}/index.md`, `${base}/info.md`, `${base}/demos.md`]; const childCandidates = ['README.md', 'index.md', 'info.md', 'demos.md'].filter(f => children.includes(f)).map(f => `${base}/${f}`); const suggestions = []; for (const s of [...guessList, ...childCandidates]) { try { const sAbs = resolveInside(context.docsRoot, s).abs; const sSt = await statSafe(sAbs); if (sSt !== null && sSt !== void 0 && sSt.isFile()) { (0, _push.default)(suggestions).call(suggestions, s); } } catch {} } return makeTextResult(JSON.stringify({ error: 'EISDIR', message: 'Path is a directory. Provide a file path.', path: relWithLeadingSlash, suggestions: Array.from(new Set(suggestions)).slice(0, 30), children }, null, 2)); } return makeTextResult(await readTextFile(abs)); }; const docsSearch = async ({ query, limit, prefix }) => { const hits = await context.searchInMarkdown(query, limit, prefix, { concurrency: 12, timeoutMs: 2000 }); return makeTextResult(JSON.stringify(hits, null, 2)); }; const componentFind = async ({ name }) => { const info = await context.resolveComponentPaths(name); return makeTextResult(JSON.stringify(info, null, 2)); }; const componentDoc = async ({ name }) => { const info = await context.resolveComponentPaths(name); const abs = resolveInside(context.docsRoot, info.doc).abs; const st = await statSafe(abs); if (!(st !== null && st !== void 0 && st.isFile())) { return makeTextResult(`Component doc not found: ${info.doc}`); } return makeTextResult(await readTextFile(abs)); }; const componentApi = async ({ name }) => { const info = await context.resolveComponentPaths(name); const abs = resolveInside(context.docsRoot, info.doc).abs; const st = await statSafe(abs); if (!(st !== null && st !== void 0 && st.isFile())) { return makeTextResult(JSON.stringify({ error: 'ENOENT', message: 'component doc not found', doc: info.doc }, null, 2)); } const jsonBlocks = extractJsonBlocks(await readTextFile(abs)); return makeTextResult(JSON.stringify({ doc: info.doc, jsonBlocks }, null, 2)); }; const componentProps = async ({ name }) => { const info = await context.resolveComponentPaths(name); const abs = resolveInside(context.docsRoot, info.doc).abs; const st = await statSafe(abs); if (!(st !== null && st !== void 0 && st.isFile())) { return makeTextResult(JSON.stringify({ error: 'ENOENT', message: 'component doc not found', doc: info.doc }, null, 2)); } const blocks = extractJsonBlocks(await readTextFile(abs)); return makeTextResult(JSON.stringify(blocks, null, 2)); }; return { docsEntry, docsIndex, docsList, docsRead, docsSearch, componentFind, componentDoc, componentApi, componentProps, docsRoot }; } async function main() { const tools = createDocsTools(); logErr(`[eufemia] docsRoot: ${tools.docsRoot}`); const server = new _mcp.McpServer({ name: 'eufemia', version: '2.2.0' }); server.registerTool('docs_entry', { title: 'Docs entry', description: 'IMPORTANT! Primary entrypoint to the Eufemia documentation. Before implementing any Eufemia-based features or examples, call mcp_eufemia_docs_entry to understand the docs structure, and learn how to use the other MCP tools correctly; then use mcp_eufemia_docs_search and mcp_eufemia_docs_read to fetch relevant documentation. Make sure you have located and carefully read the relevant getting started or first-steps documentation before you implement any examples or code snippets based on these docs. Always follow these guidelines when using the documentation: use the documentation exactly as provided; gather all required information from the documentation before using it as a reference; and do not make assumptions or infer missing details unless the documentation or user explicitly instructs you to do so.', inputSchema: EmptyInput.shape }, input => tools.docsEntry(input)); server.registerTool('docs_index', { title: 'Docs index', description: 'Return a JSON array of all known markdown and MDX documentation files under the docs root, without filtering. Use this when you need a complete, machine-readable overview of available docs paths (for example to cache, pre-index, or sanity-check the docs structure) rather than when you are looking for a specific document.', inputSchema: EmptyInput.shape }, input => tools.docsIndex(input)); server.registerTool('docs_list', { title: 'List docs', description: 'List markdown and MDX documentation files under an optional prefix, returning a JSON array of relative paths. Use this when you know the high-level area of the docs (for example `/uilib/components/` or `/uilib/extensions/forms/`) and want to discover which specific files exist there, before choosing a concrete path to read with docs_read.', inputSchema: DocsListInput.shape }, input => tools.docsList(input)); server.registerTool('docs_read', { title: 'Read docs file', description: 'Read the raw markdown or MDX content of a single documentation file, given its path relative to the docs root (for example `/uilib/components/button.md`). If the path points to a directory instead of a file, the tool returns a structured JSON payload with an error code, a list of child entries, and suggested file paths you can try instead. Use this when you already know or have discovered a specific path and need the full document content.', inputSchema: DocsReadInput.shape }, input => tools.docsRead(input)); server.registerTool('docs_search', { title: 'Search docs', description: 'Search across all markdown and MDX documentation using a free-text query, returning a JSON array of ranked matches with relevance scores and text snippets. Use this when you know what you are looking for conceptually (for example a component, feature, or concept name), but you do not know the exact file path yet. Prefer this after you have called the docs entry tool so you understand how the docs are structured. In particular, use this to find and read the appropriate getting started or first-steps documentation before you rely on any specific examples or code snippets.', inputSchema: DocsSearchInput.shape }, input => tools.docsSearch(input)); server.registerTool('component_find', { title: 'Find component', description: "Resolve the documentation paths for a single Eufemia component by its name (for example 'Button', 'Field.Address', or 'Value.Address'). Returns a JSON object that includes the doc, properties, and events paths plus existence flags. Use this when you are starting from a component name and need to know which documentation files to read or inspect next.", inputSchema: ComponentNameInput.shape }, input => tools.componentFind(input)); server.registerTool('component_doc', { title: 'Component doc', description: "Return the full markdown or MDX documentation for a single Eufemia component, identified by its name (for example 'Button' or 'Field.Address'). Use this when you need to read the human-facing docs for a component, including narrative text, examples, and API, property, event and translation descriptions, rather than just the structured JSON blocks. Before implementing any examples from these docs, make sure you have already read the relevant getting started or first-steps documentation so you apply the examples in the correct way and context.", inputSchema: ComponentNameInput.shape }, input => tools.componentDoc(input)); server.registerTool('component_api', { title: 'Component API', description: 'Extract and return all JSON code blocks from the component documentation markdown (for example structured API metadata embedded in ```json fences). Use this when you need a machine-readable representation of a component’s API or metadata, such as props or events, and you prefer to work with parsed JSON rather than free-form markdown.', inputSchema: ComponentNameInput.shape }, input => tools.componentApi(input)); server.registerTool('component_props', { title: 'Component props', description: 'Return the structured JSON blocks describing a component’s properties and events, as derived from its main documentation file. Use this when you specifically need the props- and events-level schema or configuration for a component, rather than the full documentation text, and want to drive code generation, validation, or other automated reasoning from that data.', inputSchema: ComponentNameInput.shape }, input => tools.componentProps(input)); const transport = new _stdio.StdioServerTransport(); await server.connect(transport); logErr('[eufemia] connected (stdio)'); } const shouldRun = (() => { const entryPath = _nodeProcess.default.argv[1] ? _nodePath.default.resolve(_nodeProcess.default.argv[1]) : ''; const entryName = entryPath ? _nodePath.default.basename(entryPath) : ''; const allowed = new Set(['mcp-docs-server.js', 'mcp-docs-server.mjs', 'mcp-docs-server.cjs', 'mcp-docs-server.ts', 'mcp-docs-server.mts']); return entryName ? allowed.has(entryName) : false; })(); if (shouldRun) { main().catch(e => { logErr('[eufemia] fatal:', e); _nodeProcess.default.exit(1); }); } //# sourceMappingURL=mcp-docs-server.js.map