Skip to main content
Glama

Phosphor Icons MCP Server

by lasaths
index.ts•44.8 kB
/** * Phosphor Icons MCP Server * * A Model Context Protocol server that provides seamless access to Phosphor Icons, * a flexible icon family with 6 different weights and 1,400+ beautiful icons. * * @module PhosphorIconsMCP * @version 1.0.0 */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import sharp from "sharp"; import path from "path"; import { promises as fs } from "fs"; /** * Base URL for fetching Phosphor Icons from GitHub */ const PHOSPHOR_CORE_RAW_BASE = "https://raw.githubusercontent.com/phosphor-icons/core/main/assets"; /** * GitHub API base URL for Phosphor Icons repository */ const PHOSPHOR_GITHUB_API = "https://api.github.com/repos/phosphor-icons/core/contents"; /** * Cache for all icon names to avoid repeated API calls */ let allIconsCache: string[] | null = null; let allIconsCacheTime: number = 0; const CACHE_DURATION = 3600000; // 1 hour in milliseconds /** * Convert SVG content to a PNG buffer * * @param svgContent - Raw SVG string * @param size - Optional size in pixels to resize the PNG (width and height) */ async function svgToPngBuffer(svgContent: string, size?: number): Promise<Buffer> { const pipeline = sharp(Buffer.from(svgContent)); const resized = size ? pipeline.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) : pipeline; return resized.png().toBuffer(); } /** * Cache for icon catalog data with categories */ interface IconCatalogEntry { name: string; categories: string[]; tags: string[]; } let iconCatalogCache: IconCatalogEntry[] | null = null; let iconCatalogCacheTime: number = 0; /** * Fetches the icon catalog with categories from the Phosphor Icons core package * Fetches from the GitHub repository's source catalog file * * @returns {Promise<IconCatalogEntry[]>} Array of icon entries with categories */ async function fetchIconCatalog(): Promise<IconCatalogEntry[]> { // Return cached data if available and fresh const now = Date.now(); if (iconCatalogCache && (now - iconCatalogCacheTime) < CACHE_DURATION) { return iconCatalogCache; } try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 20000); // Fetch the catalog from the core repository's source // The catalog.ts file contains the icon definitions with categories const response = await fetch("https://raw.githubusercontent.com/phosphor-icons/core/main/src/catalog.ts", { signal: controller.signal, headers: { "User-Agent": "PhosphorIconsMCP/1.0.0", }, }); clearTimeout(timeoutId); if (!response.ok) { console.warn(`Failed to fetch icon catalog from GitHub: ${response.status}`); iconCatalogCache = []; iconCatalogCacheTime = now; return []; } const text = await response.text(); // Parse the TypeScript catalog file // The file exports icons as an array with structure: // { name: string, categories: string[], tags: string[], ... } // We'll extract icon entries using regex patterns const catalogEntries: IconCatalogEntry[] = []; // Pattern to match icon entries - look for name, categories, and tags // This is a simplified parser - the actual file structure may vary const iconPattern = /name:\s*["']([^"']+)["'].*?categories:\s*\[([^\]]*)\].*?tags:\s*\[([^\]]*)\]/gs; let match; while ((match = iconPattern.exec(text)) !== null) { const name = match[1]; const categoriesStr = match[2] || ""; const tagsStr = match[3] || ""; // Parse categories and tags (remove quotes and whitespace) const categories = categoriesStr .split(",") .map(cat => cat.trim().replace(/["']/g, "")) .filter(cat => cat.length > 0); const tags = tagsStr .split(",") .map(tag => tag.trim().replace(/["']/g, "")) .filter(tag => tag.length > 0); catalogEntries.push({ name, categories, tags }); } // If regex parsing didn't work well, try a different approach // Look for individual icon objects more carefully if (catalogEntries.length === 0) { // Alternative: look for name fields and try to extract nearby categories/tags const nameMatches = text.matchAll(/name:\s*["']([^"']+)["']/g); for (const nameMatch of nameMatches) { const name = nameMatch[1]; const startPos = nameMatch.index || 0; const endPos = Math.min(startPos + 500, text.length); const iconBlock = text.substring(startPos, endPos); // Try to find categories and tags in the nearby context const categoriesMatch = iconBlock.match(/categories:\s*\[([^\]]*)\]/); const tagsMatch = iconBlock.match(/tags:\s*\[([^\]]*)\]/); const categories = categoriesMatch ? categoriesMatch[1] .split(",") .map(cat => cat.trim().replace(/["']/g, "")) .filter(cat => cat.length > 0) : []; const tags = tagsMatch ? tagsMatch[1] .split(",") .map(tag => tag.trim().replace(/["']/g, "")) .filter(tag => tag.length > 0) : []; catalogEntries.push({ name, categories, tags }); } } iconCatalogCache = catalogEntries; iconCatalogCacheTime = now; return catalogEntries; } catch (error) { console.warn(`Error fetching icon catalog: ${error instanceof Error ? error.message : String(error)}`); iconCatalogCache = []; iconCatalogCacheTime = now; return []; } } /** * Fetches all icon names from the Phosphor Icons GitHub repository * * @returns {Promise<string[]>} Array of icon names in kebab-case */ async function fetchAllIconNames(): Promise<string[]> { // Return cached data if available and fresh const now = Date.now(); if (allIconsCache && (now - allIconsCacheTime) < CACHE_DURATION) { return allIconsCache; } try { // Fetch from the regular weight directory (all weights have the same icons) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); const response = await fetch(`${PHOSPHOR_GITHUB_API}/assets/regular`, { signal: controller.signal, headers: { "User-Agent": "PhosphorIconsMCP/1.0.0", "Accept": "application/vnd.github.v3+json", }, }); clearTimeout(timeoutId); if (!response.ok) { // If API fails, fall back to popular icons console.warn(`Failed to fetch all icons from GitHub API: ${response.status}`); return POPULAR_ICONS.map(icon => icon.name); } const files = await response.json(); if (!Array.isArray(files)) { console.warn("Unexpected response format from GitHub API"); return POPULAR_ICONS.map(icon => icon.name); } // Extract icon names from filenames const iconNames = files .filter((file: any) => file.type === "file" && file.name.endsWith(".svg")) .map((file: any) => { // Remove .svg extension return file.name.replace(/\.svg$/, ""); }) .sort(); // Sort alphabetically // Cache the results allIconsCache = iconNames; allIconsCacheTime = now; return iconNames; } catch (error) { console.warn(`Error fetching all icons: ${error instanceof Error ? error.message : String(error)}`); // Fall back to popular icons on error return POPULAR_ICONS.map(icon => icon.name); } } /** * Icon metadata interface * * @interface IconMetadata * @property {string} name - Icon name in kebab-case * @property {string} [category] - Optional category classification * @property {string[]} [tags] - Optional array of descriptive tags */ interface IconMetadata { name: string; category?: string; tags?: string[]; } const POPULAR_ICONS: IconMetadata[] = [ { name: "activity", category: "health", tags: ["fitness", "health", "monitor"] }, { name: "alarm", category: "time", tags: ["clock", "time", "alert"] }, { name: "archive", category: "storage", tags: ["box", "storage", "save"] }, { name: "arrow-left", category: "arrows", tags: ["navigation", "back", "previous"] }, { name: "arrow-right", category: "arrows", tags: ["navigation", "forward", "next"] }, { name: "at", category: "communication", tags: ["email", "mention", "social"] }, { name: "bell", category: "communication", tags: ["notification", "alert", "sound"] }, { name: "book", category: "education", tags: ["read", "library", "documentation"] }, { name: "calendar", category: "time", tags: ["date", "schedule", "event"] }, { name: "camera", category: "media", tags: ["photo", "image", "capture"] }, { name: "chat", category: "communication", tags: ["message", "conversation", "talk"] }, { name: "check", category: "interface", tags: ["tick", "success", "done"] }, { name: "clock", category: "time", tags: ["time", "hour", "minute"] }, { name: "cloud", category: "weather", tags: ["sky", "storage", "sync"] }, { name: "code", category: "development", tags: ["programming", "developer", "brackets"] }, { name: "copy", category: "interface", tags: ["duplicate", "clipboard", "paste"] }, { name: "download", category: "interface", tags: ["save", "export", "arrow"] }, { name: "edit", category: "interface", tags: ["pencil", "modify", "write"] }, { name: "eye", category: "interface", tags: ["view", "see", "visible"] }, { name: "file", category: "files", tags: ["document", "paper", "text"] }, { name: "folder", category: "files", tags: ["directory", "collection", "organize"] }, { name: "gear", category: "interface", tags: ["settings", "config", "preferences"] }, { name: "heart", category: "social", tags: ["like", "favorite", "love"] }, { name: "house", category: "interface", tags: ["home", "main", "dashboard"] }, { name: "image", category: "media", tags: ["picture", "photo", "gallery"] }, { name: "info", category: "interface", tags: ["information", "help", "about"] }, { name: "link", category: "interface", tags: ["chain", "url", "hyperlink"] }, { name: "list", category: "interface", tags: ["menu", "items", "bullets"] }, { name: "lock", category: "security", tags: ["secure", "private", "protected"] }, { name: "magnifying-glass", category: "interface", tags: ["search", "find", "zoom"] }, { name: "map-pin", category: "location", tags: ["marker", "location", "place"] }, { name: "music-note", category: "media", tags: ["audio", "sound", "song"] }, { name: "paper-plane-tilt", category: "communication", tags: ["send", "message", "mail"] }, { name: "play", category: "media", tags: ["start", "video", "audio"] }, { name: "plus", category: "interface", tags: ["add", "new", "create"] }, { name: "printer", category: "office", tags: ["print", "paper", "document"] }, { name: "question", category: "interface", tags: ["help", "support", "unknown"] }, { name: "share", category: "social", tags: ["export", "send", "distribute"] }, { name: "shopping-cart", category: "commerce", tags: ["buy", "purchase", "shop"] }, { name: "star", category: "social", tags: ["favorite", "rating", "bookmark"] }, { name: "trash", category: "interface", tags: ["delete", "remove", "bin"] }, { name: "upload", category: "interface", tags: ["import", "arrow", "send"] }, { name: "user", category: "people", tags: ["person", "profile", "account"] }, { name: "warning", category: "interface", tags: ["alert", "caution", "danger"] }, { name: "x", category: "interface", tags: ["close", "cancel", "remove"] }, ]; /** * Server configuration schema * * Defines the configuration options available for the MCP server. * * @type {z.ZodObject} */ export const configSchema = z.object({ defaultWeight: z .enum(["thin", "light", "regular", "bold", "fill", "duotone"]) .default("regular") .describe("Default icon weight/style"), }); /** * Creates and configures the Phosphor Icons MCP server * * @param {Object} options - Server configuration options * @param {z.infer<typeof configSchema>} options.config - Server configuration * @returns {Promise<McpServer>} Configured MCP server instance * * @example * ```typescript * const server = createServer({ * config: { defaultWeight: "bold" } * }); * ``` */ export default function createServer({ config, }: { config: z.infer<typeof configSchema>; }) { const server = new McpServer({ name: "Phosphor Icons", version: "1.0.0", }); server.registerTool( "get-icon", { title: "Get Phosphor Icon(s)", description: "Retrieve one or more SVG icons from Phosphor Icons library. Provide either a single icon name or an array of names for batch retrieval. Returns SVG content with specified weight/style and optional color.", inputSchema: { name: z .string() .optional() .describe( "Single icon name in kebab-case (e.g., 'arrow-left', 'magnifying-glass', 'user'). Use this for a single icon, or use 'names' for multiple icons." ), names: z .array(z.string()) .optional() .describe( "Array of icon names in kebab-case (e.g., ['heart', 'star', 'user']). Use this for multiple icons, or use 'name' for a single icon. Maximum 50 icons." ), weight: z .enum(["thin", "light", "regular", "bold", "fill", "duotone"]) .optional() .describe( `Icon weight/style. Defaults to ${config.defaultWeight}. Options: thin (delicate), light (subtle), regular (balanced), bold (strong), fill (solid), duotone (two-tone)` ), color: z .string() .optional() .describe( "Icon color. Accepts hex codes (#000000), RGB (rgb(0,0,0)), named colors (red, blue), or 'currentColor'. Default: currentColor" ), size: z .number() .optional() .describe("Icon size in pixels (sets both width and height). Default: 256"), format: z .enum(["svg", "png"]) .optional() .describe("Output format. Default: svg."), saveToFile: z .string() .optional() .describe("When format=png and requesting a single icon, optional path where the PNG will be saved."), saveDir: z .string() .optional() .describe("When format=png and requesting multiple icons, optional directory where PNGs will be saved."), }, }, async ({ name, names, weight, color, size, format, saveToFile, saveDir }) => { try { // Determine if single or multiple icons const isMultiple = names !== undefined; const iconNames = isMultiple ? names : (name ? [name] : []); // Validate inputs - ensure only one method is used if (name !== undefined && names !== undefined) { return { content: [ { type: "text", text: "Error: Provide either 'name' (for single icon) or 'names' (for multiple icons), but not both.", }, ], isError: true, }; } if (!isMultiple && (!name || typeof name !== "string" || name.trim().length === 0)) { return { content: [ { type: "text", text: "Error: Either 'name' (for single icon) or 'names' (for multiple icons) must be provided.", }, ], isError: true, }; } if (isMultiple) { if (!Array.isArray(names) || names.length === 0) { return { content: [ { type: "text", text: "Error: 'names' must be a non-empty array of icon names.", }, ], isError: true, }; } if (names.length > 50) { return { content: [ { type: "text", text: "Error: Maximum 50 icons can be retrieved in a single batch request.", }, ], isError: true, }; } } // Validate size if provided if (size !== undefined && (typeof size !== "number" || size <= 0 || size > 4096)) { return { content: [ { type: "text", text: "Error: Size must be a positive number between 1 and 4096 pixels.", }, ], isError: true, }; } const selectedFormat = format || "svg"; if (saveToFile && selectedFormat !== "png") { return { content: [ { type: "text", text: "Error: 'saveToFile' is only supported when format is 'png'.", }, ], isError: true, }; } if (saveDir && selectedFormat !== "png") { return { content: [ { type: "text", text: "Error: 'saveDir' is only supported when format is 'png'.", }, ], isError: true, }; } if (saveToFile && isMultiple) { return { content: [ { type: "text", text: "Error: 'saveToFile' can only be used when requesting a single icon. Use 'saveDir' for multiple icons.", }, ], isError: true, }; } if (saveDir && !isMultiple) { return { content: [ { type: "text", text: "Error: 'saveDir' is only applicable when requesting multiple icons.", }, ], isError: true, }; } const selectedWeight = weight || config.defaultWeight; // Handle single icon if (!isMultiple) { const iconName = name!; // Sanitize icon name to prevent path traversal const sanitizedName = iconName.trim().toLowerCase().replace(/[^a-z0-9-]/g, ""); if (sanitizedName !== iconName.toLowerCase()) { return { content: [ { type: "text", text: `Error: Invalid icon name '${iconName}'. Icon names must be in kebab-case and contain only lowercase letters, numbers, and hyphens (e.g., 'arrow-left', 'user-circle').`, }, ], isError: true, }; } // File naming: regular icons have no suffix, other weights have -{weight} suffix const fileName = selectedWeight === "regular" ? `${sanitizedName}.svg` : `${sanitizedName}-${selectedWeight}.svg`; const url = `${PHOSPHOR_CORE_RAW_BASE}/${selectedWeight}/${fileName}`; // Fetch icon with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); let response: Response; try { response = await fetch(url, { signal: controller.signal, headers: { "User-Agent": "PhosphorIconsMCP/1.0.0", }, }); clearTimeout(timeoutId); } catch (fetchError) { clearTimeout(timeoutId); if (fetchError instanceof Error && fetchError.name === "AbortError") { return { content: [ { type: "text", text: "Error: Request timeout. The icon service may be temporarily unavailable.", }, ], isError: true, }; } throw fetchError; } if (!response.ok) { // Search for similar icons in the catalog to suggest alternatives const searchTerm = sanitizedName.toLowerCase(); const similarIcons = POPULAR_ICONS.filter((icon) => { const iconName = icon.name.toLowerCase(); return ( iconName.includes(searchTerm) || searchTerm.includes(iconName) || iconName.split('-').some(part => searchTerm.includes(part)) || searchTerm.split('-').some(part => iconName.includes(part)) || icon.tags?.some(tag => tag.toLowerCase().includes(searchTerm)) || icon.category?.toLowerCase().includes(searchTerm) ); }).slice(0, 5); let suggestionText = ""; if (similarIcons.length > 0) { const suggestions = similarIcons .map((icon) => `- **${icon.name}** (${icon.category || "general"})`) .join("\n"); suggestionText = `\n\n**Similar icons found in catalog:**\n${suggestions}\n\nTry using the 'search-icons' tool with "${sanitizedName}" to find more options, or use 'list-categories' to browse available icons.`; } else { suggestionText = `\n\n**Suggestions:**\n- Use the 'search-icons' tool with "${sanitizedName}" to find similar icons\n- Use 'list-categories' to browse available icon categories\n- Check the full catalog at https://phosphoricons.com`; } return { content: [ { type: "text", text: `Error: Icon '${sanitizedName}' not found with weight '${selectedWeight}'.${suggestionText}\n\nIcon names should be in kebab-case (e.g., 'arrow-left', 'user-circle'). Available weights: thin, light, regular, bold, fill, duotone.`, }, ], isError: true, }; } let svgContent = await response.text(); // Validate SVG content if (!svgContent || !svgContent.trim().startsWith("<svg")) { return { content: [ { type: "text", text: "Error: Invalid SVG content received from the icon service.", }, ], isError: true, }; } // Apply color if specified if (color) { if (selectedWeight === "fill") { svgContent = svgContent.replace(/fill="[^"]*"/g, `fill="${color}"`); } else if (selectedWeight === "duotone") { svgContent = svgContent.replace(/fill="[^"]*"/g, `fill="${color}"`); svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="${color}"`); } else { svgContent = svgContent.replace(/fill="[^"]*"/g, `fill="${color}"`); svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="${color}"`); } } // Apply size if specified if (size) { if (!svgContent.includes('width=')) { svgContent = svgContent.replace(/<svg([^>]*)>/, `<svg$1 width="${size}" height="${size}">`); } else { svgContent = svgContent.replace(/width="[^"]*"/, `width="${size}"`); svgContent = svgContent.replace(/height="[^"]*"/, `height="${size}"`); } } const colorInfo = color ? ` with color '${color}'` : ""; const sizeInfo = size ? ` at ${size}px` : ""; if (selectedFormat === "png") { const pngBuffer = await svgToPngBuffer(svgContent, size); let savedPathInfo = ""; if (saveToFile) { const targetPath = saveToFile.toLowerCase().endsWith(".png") ? saveToFile : `${saveToFile}.png`; const resolvedPath = path.resolve(targetPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, pngBuffer); savedPathInfo = `- Saved to: ${resolvedPath}\n`; } const dataUri = `data:image/png;base64,${pngBuffer.toString("base64")}`; return { content: [ { type: "text", text: `# ${iconName} (${selectedWeight}${colorInfo}${sizeInfo})\n\n- Format: png\n- Bytes: ${pngBuffer.length}\n${savedPathInfo}\nData URI (use as <img src=\"...\">):\n\`\`\`\n${dataUri}\n\`\`\``, }, ], }; } return { content: [ { type: "text", text: `# ${iconName} (${selectedWeight}${colorInfo}${sizeInfo})\n\n\`\`\`svg\n${svgContent}\n\`\`\`\n\nYou can use this SVG directly in your HTML or React components.`, }, ], }; } // Handle multiple icons const results: string[] = []; for (const iconName of iconNames) { try { // Validate and sanitize each icon name if (!iconName || typeof iconName !== "string" || iconName.trim().length === 0) { results.push(`## ${iconName}\nāŒ Error: Invalid icon name`); continue; } const sanitizedName = iconName.trim().toLowerCase().replace(/[^a-z0-9-]/g, ""); const baseFileName = selectedWeight === "regular" ? sanitizedName : `${sanitizedName}-${selectedWeight}`; const fileName = `${baseFileName}.svg`; const url = `${PHOSPHOR_CORE_RAW_BASE}/${selectedWeight}/${fileName}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); let response: Response; try { response = await fetch(url, { signal: controller.signal, headers: { "User-Agent": "PhosphorIconsMCP/1.0.0", }, }); clearTimeout(timeoutId); } catch (fetchError) { clearTimeout(timeoutId); if (fetchError instanceof Error && fetchError.name === "AbortError") { results.push(`## ${iconName}\nāŒ Error: Request timeout`); continue; } throw fetchError; } if (response.ok) { let svgContent = await response.text(); // Apply color if specified if (color) { if (selectedWeight === "fill") { svgContent = svgContent.replace(/fill="[^"]*"/g, `fill="${color}"`); } else if (selectedWeight === "duotone") { svgContent = svgContent.replace(/fill="[^"]*"/g, `fill="${color}"`); svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="${color}"`); } else { svgContent = svgContent.replace(/fill="[^"]*"/g, `fill="${color}"`); svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="${color}"`); } } // Apply size if specified if (size) { if (!svgContent.includes('width=')) { svgContent = svgContent.replace(/<svg([^>]*)>/, `<svg$1 width="${size}" height="${size}">`); } else { svgContent = svgContent.replace(/width="[^"]*"/, `width="${size}"`); svgContent = svgContent.replace(/height="[^"]*"/, `height="${size}"`); } } if (selectedFormat === "png") { const pngBuffer = await svgToPngBuffer(svgContent, size); let savedPathText = ""; if (saveDir) { const pngDir = path.resolve(saveDir); const targetPath = path.join(pngDir, `${baseFileName}.png`); await fs.mkdir(pngDir, { recursive: true }); await fs.writeFile(targetPath, pngBuffer); savedPathText = `Saved to: ${targetPath}\n`; } const dataUri = `data:image/png;base64,${pngBuffer.toString("base64")}`; results.push(`## ${iconName}\nFormat: png • Bytes: ${pngBuffer.length}\n${savedPathText}\`\`\`\n${dataUri}\n\`\`\``); } else { results.push(`## ${iconName}\n\`\`\`svg\n${svgContent}\n\`\`\``); } } else { // Search for similar icons to suggest alternatives const searchTerm = sanitizedName.toLowerCase(); const similarIcons = POPULAR_ICONS.filter((icon) => { const iconName = icon.name.toLowerCase(); return ( iconName.includes(searchTerm) || searchTerm.includes(iconName) || iconName.split('-').some(part => searchTerm.includes(part)) || searchTerm.split('-').some(part => iconName.includes(part)) || icon.tags?.some(tag => tag.toLowerCase().includes(searchTerm)) || icon.category?.toLowerCase().includes(searchTerm) ); }).slice(0, 3); let suggestionText = ""; if (similarIcons.length > 0) { const suggestions = similarIcons .map((icon) => `- ${icon.name}`) .join(", "); suggestionText = ` (Similar: ${suggestions})`; } results.push(`## ${iconName}\nāŒ Not found${suggestionText}`); } } catch (error) { results.push( `## ${iconName}\nāŒ Error: ${error instanceof Error ? error.message : String(error)}` ); } } const colorInfo = color ? ` • Color: ${color}` : ""; const sizeInfo = size ? ` • Size: ${size}px` : ""; const formatInfo = selectedFormat === "png" ? " • Format: png" : ""; return { content: [ { type: "text", text: `# Batch Icon Results (${selectedWeight}${colorInfo}${sizeInfo}${formatInfo})\n\n${results.join("\n\n")}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error fetching icon: ${errorMessage}. Please verify the icon name and try again.`, }, ], isError: true, }; } } ); server.registerTool( "search-icons", { title: "Search Phosphor Icons", description: "Search for icons by name, category, or tags. Returns a list of matching icons.", inputSchema: { query: z .string() .describe("Search query (searches icon names, categories, and tags)"), limit: z .number() .optional() .default(10) .describe("Maximum number of results to return"), }, }, async ({ query, limit = 10 }) => { // Validate inputs if (!query || typeof query !== "string" || query.trim().length === 0) { return { content: [ { type: "text", text: "Error: Search query is required and must be a non-empty string.", }, ], isError: true, }; } if (limit !== undefined && (typeof limit !== "number" || limit <= 0 || limit > 100)) { return { content: [ { type: "text", text: "Error: Limit must be a positive number between 1 and 100.", }, ], isError: true, }; } const searchTerm = query.toLowerCase().trim(); const safeLimit = Math.min(Math.max(1, Math.floor(limit || 10)), 100); // Fetch all icons for searching const allIconNames = await fetchAllIconNames(); // Create a map of popular icons for metadata const popularIconsMap = new Map( POPULAR_ICONS.map(icon => [icon.name, icon]) ); // Search through all icons by name first const nameMatches = allIconNames.filter((iconName) => { return iconName.toLowerCase().includes(searchTerm); }); // Also search popular icons by category and tags const metadataMatches = POPULAR_ICONS.filter((icon) => { return ( icon.category?.toLowerCase().includes(searchTerm) || icon.tags?.some((tag) => tag.toLowerCase().includes(searchTerm)) ); }).map(icon => icon.name); // Combine and deduplicate matches, prioritizing name matches const allMatches = Array.from(new Set([...nameMatches, ...metadataMatches])); // Sort: popular icons with metadata first, then others const sortedMatches = allMatches.sort((a, b) => { const aIsPopular = popularIconsMap.has(a); const bIsPopular = popularIconsMap.has(b); if (aIsPopular && !bIsPopular) return -1; if (!aIsPopular && bIsPopular) return 1; return a.localeCompare(b); }); const matches = sortedMatches.slice(0, safeLimit); if (matches.length === 0) { // Suggest using list-categories to see what's available const categories = Array.from( new Set( POPULAR_ICONS.map((icon) => icon.category).filter( (cat): cat is string => cat !== undefined ) ) ).sort(); const categoryList = categories.slice(0, 5).join(", "); return { content: [ { type: "text", text: `No icons found matching "${query}".\n\n**Available options:**\n- Use 'list-categories' to see all icon categories (${categories.length} categories available, e.g., ${categoryList})\n- Try different search keywords\n- Check the full catalog at https://phosphoricons.com\n\n**Tip:** Use 'list-categories' first to see what's available, then search within specific categories.`, }, ], }; } const resultText = matches .map((iconName) => { const popularIcon = popularIconsMap.get(iconName); if (popularIcon) { const tags = popularIcon.tags?.join(", ") || "none"; return `- **${iconName}** (${popularIcon.category || "general"})\n Tags: ${tags}`; } else { return `- **${iconName}** (general)`; } }) .join("\n\n"); // Proactively suggest retrieving icons const exampleIconName = matches[0]; const suggestionText = exampleIconName ? `\n\n**Quick start:** Use \`get-icon\` with name "${exampleIconName}" to retrieve the SVG:\n\`get-icon({ name: "${exampleIconName}", weight: "regular" })\`\n\nOr retrieve multiple icons at once: \`get-icon({ names: ["${exampleIconName}", "star", "heart"] })\`.` : ""; return { content: [ { type: "text", text: `Found ${matches.length} icon(s) matching "${query}":\n\n${resultText}${suggestionText}\n\nUse the 'get-icon' tool with any icon name above to retrieve the SVG.`, }, ], }; } ); server.registerTool( "list-categories", { title: "List Icon Categories", description: "Get a list of all icon categories available in Phosphor Icons.", inputSchema: {}, }, async () => { const categories = Array.from( new Set( POPULAR_ICONS.map((icon) => icon.category).filter( (cat): cat is string => cat !== undefined ) ) ).sort(); const categoryList = categories .map((cat) => { const count = POPULAR_ICONS.filter( (icon) => icon.category === cat ).length; const exampleIcon = POPULAR_ICONS.find(icon => icon.category === cat); const example = exampleIcon ? ` (e.g., ${exampleIcon.name})` : ""; return `- **${cat}**: ${count} icon(s)${example}`; }) .join("\n"); const exampleCategory = categories[0]; const exampleIcons = POPULAR_ICONS .filter(icon => icon.category === exampleCategory) .slice(0, 3) .map(icon => icon.name) .join(", "); return { content: [ { type: "text", text: `# Phosphor Icons Categories\n\n${categoryList}\n\n**Next steps:**\n- Use 'search-icons' with a category name (e.g., "${exampleCategory}") to find icons in that category\n- Use 'search-icons' with an icon name (e.g., "${exampleIcons}") to find specific icons\n- Use 'get-icon' with any icon name to retrieve the SVG\n\n**Example:** \`search-icons({ query: "${exampleCategory}" })\` to see all ${exampleCategory} icons.`, }, ], }; } ); server.registerResource( "icon-catalog", "phosphor://catalog", { title: "Phosphor Icons Catalog", description: "Complete catalog of all Phosphor Icons with metadata", }, async (uri) => { // Fetch all icon names and catalog data const allIconNames = await fetchAllIconNames(); const catalogData = await fetchIconCatalog(); // Create maps for quick lookup const catalogMap = new Map(catalogData.map(entry => [entry.name, entry])); const popularIconsMap = new Map( POPULAR_ICONS.map(icon => [icon.name, icon]) ); // Organize icons by category // Icons can have multiple categories, so we'll use the first one for grouping const iconsByCategory = new Map<string, Array<{name: string, tags: string[]}>>(); const uncategorizedIcons: Array<{name: string, tags: string[]}> = []; for (const iconName of allIconNames) { const catalogEntry = catalogMap.get(iconName); const popularIcon = popularIconsMap.get(iconName); // Use catalog data if available, otherwise fall back to popular icons const categories = catalogEntry?.categories || (popularIcon?.category ? [popularIcon.category] : []); const tags = catalogEntry?.tags || popularIcon?.tags || []; if (categories.length > 0) { // Use the first category for grouping const primaryCategory = categories[0]; if (!iconsByCategory.has(primaryCategory)) { iconsByCategory.set(primaryCategory, []); } iconsByCategory.get(primaryCategory)!.push({ name: iconName, tags }); } else { uncategorizedIcons.push({ name: iconName, tags }); } } // Generate catalog text organized by category const categorySections: string[] = []; // Sort categories alphabetically const sortedCategories = Array.from(iconsByCategory.keys()).sort(); for (const category of sortedCategories) { const icons = iconsByCategory.get(category)!; const iconList = icons.map(({ name, tags }) => { const tagsStr = tags.length > 0 ? tags.join(", ") : ""; return ` - **${name}**${tagsStr ? ` (${tagsStr})` : ""}`; }).join("\n"); categorySections.push(`## ${category.charAt(0).toUpperCase() + category.slice(1)}\n\n${iconList}\n`); } // Add uncategorized icons if any if (uncategorizedIcons.length > 0) { const uncategorizedList = uncategorizedIcons.map(({ name, tags }) => { const tagsStr = tags.length > 0 ? tags.join(", ") : ""; return ` - **${name}**${tagsStr ? ` (${tagsStr})` : ""}`; }).join("\n"); categorySections.push(`## General (Uncategorized)\n\n${uncategorizedList}\n`); } const catalogText = categorySections.join("\n"); const catalogCount = catalogData.length; const totalCount = allIconNames.length; const metadataNote = catalogCount > 0 && catalogCount < totalCount ? `\n\n**Note:** ${catalogCount} icons have category metadata from the core package. The remaining ${totalCount - catalogCount} icons are listed under "General (Uncategorized)".` : catalogCount === 0 ? `\n\n**Note:** Using fallback categorization from popular icons. ${POPULAR_ICONS.length} icons have detailed metadata.` : ""; return { contents: [ { uri: uri.href, text: `# Phosphor Icons Catalog\n\n**Total Icons: ${totalCount}**\n\nIcons organized by category:\n\n${catalogText}${metadataNote}\n\n---\n\nFull catalog: https://phosphoricons.com\nGitHub: https://github.com/phosphor-icons/core`, mimeType: "text/markdown", }, ], }; } ); server.registerResource( "icon-weights-info", "phosphor://weights", { title: "Icon Weights Information", description: "Information about available icon weights/styles in Phosphor Icons", }, async (uri) => { const weightsInfo = `# Phosphor Icon Weights Phosphor Icons are available in 6 different weights/styles: ## 1. Thin - Delicate, minimal strokes - Best for: Large sizes, elegant interfaces ## 2. Light - Subtle, refined appearance - Best for: Modern, clean designs ## 3. Regular (Default) - Balanced, versatile - Best for: General use, body text sizes ## 4. Bold - Strong, impactful presence - Best for: Emphasis, small sizes ## 5. Fill - Solid, filled shapes - Best for: Strong emphasis, iconography ## 6. Duotone - Two-tone design with depth - Best for: Visual interest, brand identity --- Configure your default weight in the server settings or specify per-request.`; return { contents: [ { uri: uri.href, text: weightsInfo, mimeType: "text/markdown", }, ], }; } ); server.registerPrompt( "implement-icon", { title: "Icon Implementation Guide", description: "Get guidance on implementing a Phosphor icon in your project", argsSchema: { iconName: z.string().describe("Name of the icon to implement"), framework: z .enum(["html", "react", "vue", "svelte", "angular"]) .optional() .describe("Frontend framework (default: react)"), }, }, async ({ iconName, framework = "react" }) => { // Validate inputs if (!iconName || typeof iconName !== "string" || iconName.trim().length === 0) { return { messages: [ { role: "user", content: { type: "text", text: "Error: Icon name is required and must be a non-empty string.", }, }, ], }; } const selectedFramework = framework || "react"; const sanitizedIconName = iconName.trim(); const iconComponentName = sanitizedIconName .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(""); const guides: Record<string, string> = { html: `To use '${sanitizedIconName}' in HTML:\n1. Get the SVG using the 'get-icon' tool\n2. Copy the SVG code directly into your HTML\n3. Customize with CSS classes`, react: `To use '${sanitizedIconName}' in React:\n1. Get the SVG using the 'get-icon' tool\n2. Or install: npm install @phosphor-icons/react\n\nWith package:\nimport { ${iconComponentName} } from '@phosphor-icons/react';\n<${iconComponentName} size={32} weight="bold" />`, vue: `To use '${sanitizedIconName}' in Vue:\n1. Get the SVG using the 'get-icon' tool\n2. Or install: npm install @phosphor-icons/vue`, svelte: `To use '${sanitizedIconName}' in Svelte:\n1. Get the SVG using the 'get-icon' tool\n2. Check https://phosphoricons.com for Svelte packages`, angular: `To use '${sanitizedIconName}' in Angular:\n1. Get the SVG using the 'get-icon' tool\n2. Check https://phosphoricons.com for Angular packages`, }; const guideText = guides[selectedFramework] || guides.react; return { messages: [ { role: "user", content: { type: "text", text: `Please help me implement the '${sanitizedIconName}' Phosphor icon in my ${selectedFramework} project. ${guideText}`, }, }, ], }; } ); return server.server; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/lasaths/phosphor-icons-mcp'

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