@heilgar/shadcn-ui-mcp-server
Version:
MCP server for shadcn/ui component references
221 lines (220 loc) • 8.85 kB
JavaScript
import { load } from "cheerio";
export const RETRY_ATTEMPTS = 3;
export const RETRY_DELAY_MS = 500;
export const BASE_URL = "https://ui.shadcn.com";
export const RAW_GITHUB_URL = "https://raw.githubusercontent.com/shadcn-ui/ui/refs/heads/main/apps";
export const BLOCK_PAGES = [
`${BASE_URL}/blocks/sidebar`,
`${BASE_URL}/blocks/authentication`,
];
export const RUNTIME_REPLACEMENTS = {
pnpm: 'pnpm dlx',
yarn: 'yarn dlx',
bun: 'bunx'
};
export const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/;
export const DESCRIPTION_PATTERNS = [
/description:\s*["']([^"']+)["']/,
/description:\s*([^\n]+)/,
/description\s*:\s*["']([^"']+)["']/
];
export const FIRST_PARAGRAPH_REGEX = /---\n[\s\S]*?\n---\n\n([^\n]+)/;
export const LINKS_REGEX = /links:\n([\s\S]*?)(?=\n\w|$)/;
export const CLI_COMMAND_REGEX = /```bash\nnpx shadcn@latest add [^\n]+\n```/;
export const USAGE_REGEX = /## Usage\n\n([\s\S]*?)(?=\n## |$)/;
export const CODE_BLOCKS_REGEX = /```(?:tsx|ts|jsx|js)([\s\S]*?)```/g;
export const CODE_BLOCK_CLEANUP_REGEX = /```(?:tsx|ts|jsx|js)\n|```$/g;
export const resourceCache = new Map();
const loadCheerio = (html) => load(html, {
decodeEntities: true
});
export const validateRuntime = (runtime) => !runtime || ['npm', 'pnpm', 'yarn', 'bun'].includes(runtime);
export const extractDescription = (frontmatter, mdxContent) => {
for (const pattern of DESCRIPTION_PATTERNS) {
const match = frontmatter.match(pattern);
if (match)
return match[1].trim();
}
const firstParagraphMatch = mdxContent.match(FIRST_PARAGRAPH_REGEX);
return firstParagraphMatch ? firstParagraphMatch[1].trim() : '';
};
export const extractLinks = (frontmatter) => {
const links = [];
const linksMatch = frontmatter.match(LINKS_REGEX);
if (linksMatch) {
const linksContent = linksMatch[1];
const docLinkMatch = linksContent.match(/doc:\s*([^\n]+)/);
const apiLinkMatch = linksContent.match(/api:\s*([^\n]+)/);
if (docLinkMatch)
links.push(docLinkMatch[1].trim());
if (apiLinkMatch)
links.push(apiLinkMatch[1].trim());
}
return links;
};
export const getCliCommand = (cliCommand, runtime) => {
if (!runtime || runtime === 'npm')
return cliCommand;
return cliCommand.replace('npx', RUNTIME_REPLACEMENTS[runtime]);
};
export const createResponse = (text, isError = false, mimeType) => ({
content: [{ type: "text", text, ...(mimeType && { mimeType }) }],
...(isError && { isError })
});
export const handleError = (error, prefix) => createResponse(`${prefix}: ${error instanceof Error ? error.message : String(error)}`, true);
export async function fetchWithRetry(url, retries = RETRY_ATTEMPTS, delay = RETRY_DELAY_MS) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
return response;
}
catch (error) {
if (retries <= 1)
throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return fetchWithRetry(url, retries - 1, delay * 2); // Exponential backoff
}
}
async function cacheResource(key, fetchFn, cache) {
// Check cache first
if (cache.has(key)) {
return cache.get(key);
}
try {
const data = await fetchFn();
cache.set(key, data);
return data;
}
catch (error) {
throw new Error(`Failed to fetch data for key '${key}': ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function fetchAndCache(key, fetchFn, transformFn) {
try {
const rawData = await fetchFn();
const transformedData = transformFn(rawData);
transformedData.forEach(data => resourceCache.set(data.name, data));
return transformedData;
}
catch (error) {
throw new Error(`Failed to fetch and transform data for key '${key}': ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function fetchAndCacheComponentData(component) {
if (!component || typeof component !== 'string') {
throw new Error('Invalid component name');
}
// Sanitize component name
const sanitizedComponent = component.replace(/[^a-zA-Z0-9-_]/g, '');
if (sanitizedComponent !== component) {
throw new Error(`Invalid component name: ${component}`);
}
// Check cache
if (resourceCache.has(component)) {
return resourceCache.get(component);
}
const docSubPath = `www/content/docs/components`;
const url = `${RAW_GITHUB_URL}/${docSubPath}/${component}.mdx`;
const transformComponentData = (mdxContent) => {
const frontmatterMatch = mdxContent.match(FRONTMATTER_REGEX);
const frontmatter = frontmatterMatch ? frontmatterMatch[1] : '';
const description = extractDescription(frontmatter, mdxContent);
const links = extractLinks(frontmatter);
const cliCommandMatch = mdxContent.match(CLI_COMMAND_REGEX);
const cliCommand = cliCommandMatch ? cliCommandMatch[0].replace(/```bash\n|\n```/g, '').trim() : undefined;
let commands = undefined;
if (cliCommand) {
commands = [{
npm: cliCommand,
pnpm: getCliCommand(cliCommand, 'pnpm'),
yarn: getCliCommand(cliCommand, 'yarn'),
bun: getCliCommand(cliCommand, 'bun')
}];
}
return [{
name: component,
description,
doc: mdxContent,
commands,
links: links.length > 0 ? links : undefined,
isBlock: false
}];
};
const [componentData] = await fetchAndCache(component, async () => {
const response = await fetchWithRetry(url);
return response.text();
}, transformComponentData);
return componentData;
}
export async function fetchAndCacheBlocks() {
const transformBlocks = (blockPages) => {
const allBlocks = blockPages.flat();
return allBlocks.map((block) => ({
name: block.name,
description: block.description,
doc: block.doc,
commands: [{
npm: block.command,
pnpm: getCliCommand(block.command, 'pnpm'),
yarn: getCliCommand(block.command, 'yarn'),
bun: getCliCommand(block.command, 'bun')
}],
isBlock: true
}));
};
return fetchAndCache('blocks', async () => Promise.all(BLOCK_PAGES.map(parseBlocksFromPage)), transformBlocks);
}
export async function parseBlocksFromPage(url) {
if (!url || !url.startsWith('https://')) {
throw new Error(`Invalid URL: ${url}`);
}
try {
const response = await fetchWithRetry(url);
const html = await response.text();
const $ = loadCheerio(html);
const blocks = [];
$('.container-wrapper.flex-1 div[id]').each((_, el) => {
const $block = $(el);
const id = $block.attr('id');
if (id && !id.startsWith('radix-')) {
const anchor = $block.find('div.flex.w-full.items-center.gap-2.md\\:pr-\\[14px\\] > a');
const description = anchor.text().trim();
const command = $block.find('div.flex.w-full.items-center.gap-2.md\\:pr-\\[14px\\] > div.ml-auto.hidden.items-center.gap-2.md\\:flex > div.flex.h-7.items-center.gap-1.rounded-md.border.p-\\[2px\\] > button > span').text().trim();
const doc = $block.find('code').first().text().trim();
blocks.push({ name: id, description, command, doc });
}
});
if (blocks.length === 0) {
console.error(`Warning: No blocks found at ${url}`);
}
return blocks;
}
catch (error) {
throw new Error(`Failed to parse blocks from ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
}
export function parseComponentsFromHtml(html) {
if (!html || typeof html !== 'string') {
throw new Error('Invalid HTML content');
}
try {
const $ = loadCheerio(html);
const components = $('a[href^="/docs/components/"]')
.map((_, el) => {
const href = $(el).attr('href');
return href?.split('/').pop();
})
.get()
.filter((name) => Boolean(name))
.sort();
if (components.length === 0) {
console.error('Warning: No components found in HTML');
}
return components;
}
catch (error) {
throw new Error(`Failed to parse components: ${error instanceof Error ? error.message : String(error)}`);
}
}