diff --git a/package-lock.json b/package-lock.json index a36534d..17f5a83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18365,9 +18365,9 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -18822,7 +18822,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", - "zod": "^3.23.0" + "zod": "^4.3.5" }, "bin": { "composter-mcp": "bin/composter-mcp.js" @@ -18833,15 +18833,6 @@ "engines": { "node": ">=18.0.0" } - }, - "packages/mcp/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/packages/mcp/jsconfig.json b/packages/mcp/jsconfig.json new file mode 100644 index 0000000..e5fe0d4 --- /dev/null +++ b/packages/mcp/jsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ES6", + "checkJs": true, + "jsx": "preserve", + "moduleResolution": "node", + "skipLibCheck": true, + "noLib": false + }, + "exclude": [ + "node_modules", + "**/node_modules/*", + "dist", + "build", + ".git" + ] +} \ No newline at end of file diff --git a/packages/mcp/lib/auth.js b/packages/mcp/lib/auth.js index 528a7aa..6955aca 100644 --- a/packages/mcp/lib/auth.js +++ b/packages/mcp/lib/auth.js @@ -16,7 +16,7 @@ export function getBaseUrl() { return "http://localhost:3000/api"; } // Default to production - return "https://composter.onrender.com/api"; + return "https://composter-api.vercel.app/api"; } // Load session from CLI's session file diff --git a/packages/mcp/lib/factory.js b/packages/mcp/lib/factory.js index b357141..e78754a 100644 --- a/packages/mcp/lib/factory.js +++ b/packages/mcp/lib/factory.js @@ -10,585 +10,7 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getAuthToken, getBaseUrl } from "./auth.js"; - -// ============================================================================ -// API LAYER -// ============================================================================ - -/** - * Makes authenticated API requests to the Composter backend - */ -async function api(path, options = {}) { - const token = getAuthToken(); - const baseUrl = getBaseUrl(); - - const response = await fetch(`${baseUrl}${path}`, { - ...options, - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - ...options.headers, - }, - }); - - return response; -} - -// ============================================================================ -// DATA FETCHERS -// ============================================================================ - -async function getAllCategories() { - const res = await api("/categories"); - if (!res.ok) throw new Error(await getErrorMessage(res)); - const data = await res.json(); - return data.categories || []; -} - -async function getAllComponents() { - const res = await api("/components/list"); - if (!res.ok) throw new Error(await getErrorMessage(res)); - const data = await res.json(); - return data.components || []; -} - -async function getComponentsByCategory(categoryName) { - const res = await api(`/components/list-by-category?category=${encodeURIComponent(categoryName)}`); - if (res.status === 404) return null; // Category not found - if (!res.ok) throw new Error(await getErrorMessage(res)); - const data = await res.json(); - return data.components || []; -} - -async function getComponent(category, title) { - const res = await api(`/components?category=${encodeURIComponent(category)}&title=${encodeURIComponent(title)}`); - if (res.status === 404) return null; - if (!res.ok) throw new Error(await getErrorMessage(res)); - const data = await res.json(); - return data.component; -} - -async function searchComponents(query) { - const res = await api(`/components/search?q=${encodeURIComponent(query)}`); - if (!res.ok) throw new Error(await getErrorMessage(res)); - const data = await res.json(); - return data.components || []; -} - -async function getErrorMessage(res) { - try { - const data = await res.json(); - return data.message || data.error || res.statusText; - } catch { - return res.statusText; - } -} - -// ============================================================================ -// FORMATTERS - Beautiful output for chat interfaces -// ============================================================================ - -function formatCategoriesList(categories) { - if (!categories.length) { - return `๐Ÿ“ **No categories found** - -Your vault is empty! Get started by creating a category: - -\`\`\`bash -composter mkcat buttons -\`\`\` - -Then push your first component: - -\`\`\`bash -composter push buttons "MyButton" ./src/components/Button.jsx -\`\`\``; - } - - const list = categories.map(c => ` โ€ข **${c.name}**`).join("\n"); - return `๐Ÿ“ **Your Categories** (${categories.length}) - -${list} - -๐Ÿ’ก *Ask me to "show components in [category]" to explore further*`; -} - -function formatComponentsList(components, categoryName = null) { - if (!components.length) { - const context = categoryName ? ` in "${categoryName}"` : ""; - return `๐Ÿ“ฆ **No components found${context}** - -Push components using the CLI: - -\`\`\`bash -composter push ${categoryName || "category"} "ComponentName" ./path/to/component.jsx -\`\`\``; - } - - const header = categoryName - ? `๐Ÿ“ฆ **Components in "${categoryName}"** (${components.length})` - : `๐Ÿ“ฆ **All Components** (${components.length})`; - - const list = components.map(c => { - const category = c.category?.name || "uncategorized"; - const date = new Date(c.createdAt).toLocaleDateString(); - const deps = getDepsCount(c); - const depsLabel = deps > 0 ? ` ยท ${deps} deps` : ""; - - return categoryName - ? ` โ€ข **${c.title}** โ€” ${date}${depsLabel}` - : ` โ€ข **${c.title}** *(${category})* โ€” ${date}${depsLabel}`; - }).join("\n"); - - return `${header} - -${list} - -๐Ÿ’ก *Ask me to "read [component] from [category]" to see the code*`; -} - -function formatComponentDetail(component, categoryName) { - if (!component) { - return `โŒ **Component not found** - -Try searching: *"find [keyword]"*`; - } - - // Parse multi-file or single-file code - let codeBlocks = ""; - try { - const files = JSON.parse(component.code); - codeBlocks = Object.entries(files) - .map(([filePath, content]) => { - const lang = getLanguageFromPath(filePath); - return `### ๐Ÿ“„ \`${filePath}\` - -\`\`\`${lang} -${content} -\`\`\``; - }) - .join("\n\n"); - } catch { - // Single file component - codeBlocks = `\`\`\`tsx -${component.code} -\`\`\``; - } - - // Format dependencies - let depsSection = ""; - if (component.dependencies && Object.keys(component.dependencies).length > 0) { - const deps = Object.entries(component.dependencies) - .map(([pkg, ver]) => ` โ€ข \`${pkg}\`: ${ver}`) - .join("\n"); - - const installCmd = Object.keys(component.dependencies).join(" "); - depsSection = ` ---- - -### ๐Ÿ“ฆ Dependencies - -${deps} - -**Install command:** -\`\`\`bash -npm install ${installCmd} -\`\`\` -`; - } - - const createdDate = new Date(component.createdAt).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric" - }); - - return `# ${component.title} - -| Property | Value | -|----------|-------| -| **Category** | ${categoryName} | -| **Created** | ${createdDate} | - ---- - -## Source Code - -${codeBlocks} -${depsSection} ---- - -๐Ÿ’ก *Pull this component:* \`composter pull ${categoryName} "${component.title}" ./components/\``; -} - -function formatSearchResults(components, query) { - if (!components.length) { - return `๐Ÿ” **No results for "${query}"** - -Try: - โ€ข Different keywords - โ€ข *"list categories"* to see what's available - โ€ข *"show all components"* to browse everything`; - } - - const results = components.slice(0, 10).map(c => { - const category = c.category?.name || "uncategorized"; - return ` โ€ข **${c.title}** in *${category}*`; - }).join("\n"); - - const moreNote = components.length > 10 - ? `\n\n*...and ${components.length - 10} more results*` - : ""; - - return `๐Ÿ” **Search results for "${query}"** (${components.length}) - -${results}${moreNote} - -๐Ÿ’ก *Ask me to "read [component] from [category]" to see the full code*`; -} - -// ============================================================================ -// HELPERS -// ============================================================================ - -function getDepsCount(component) { - if (!component.dependencies) return 0; - try { - const deps = typeof component.dependencies === "string" - ? JSON.parse(component.dependencies) - : component.dependencies; - return Object.keys(deps).length; - } catch { - return 0; - } -} - -function getLanguageFromPath(filePath) { - const ext = filePath.split(".").pop()?.toLowerCase(); - const langMap = { - tsx: "tsx", - ts: "typescript", - jsx: "jsx", - js: "javascript", - css: "css", - scss: "scss", - json: "json", - md: "markdown" - }; - return langMap[ext] || "tsx"; -} - -function normalizeText(text) { - return text.toLowerCase().trim().replace(/\s+/g, " "); -} - -/** - * Fuzzy match a component by title across all components - */ -async function fuzzyFindComponent(searchTitle) { - const allComponents = await getAllComponents(); - const normalized = normalizeText(searchTitle); - - // Exact match first - let match = allComponents.find(c => - normalizeText(c.title) === normalized - ); - if (match) return match; - - // Partial match - match = allComponents.find(c => - normalizeText(c.title).includes(normalized) || - normalized.includes(normalizeText(c.title)) - ); - if (match) return match; - - // Word-based match - const searchWords = normalized.split(/\s+/); - match = allComponents.find(c => { - const titleNorm = normalizeText(c.title); - return searchWords.every(word => titleNorm.includes(word)); - }); - - return match; -} - -/** - * Fuzzy match a category by name - */ -async function fuzzyFindCategory(searchName) { - const categories = await getAllCategories(); - const normalized = normalizeText(searchName); - - // Exact match - let match = categories.find(c => normalizeText(c.name) === normalized); - if (match) return match.name; - - // Partial match - match = categories.find(c => - normalizeText(c.name).includes(normalized) || - normalized.includes(normalizeText(c.name)) - ); - - return match?.name || null; -} - -// ============================================================================ -// NATURAL LANGUAGE PROCESSOR -// ============================================================================ - -/** - * Intelligent query parser that understands natural language requests - * and routes them to the appropriate handler - */ -async function processNaturalQuery(query) { - const q = normalizeText(query); - - // ------------------------------------------------------------------------- - // PATTERN: List all categories - // "list categories", "show my categories", "what categories do i have" - // ------------------------------------------------------------------------- - if ( - /\b(list|show|get|what|my)\b.*\bcategor(y|ies)\b/.test(q) || - /\bcategor(y|ies)\b.*\b(list|show|have)\b/.test(q) || - q === "categories" - ) { - const categories = await getAllCategories(); - return formatCategoriesList(categories); - } - - // ------------------------------------------------------------------------- - // PATTERN: List all components - // "show all components", "list everything", "what components do i have" - // ------------------------------------------------------------------------- - if ( - /\b(all|every)\b.*\bcomponent/.test(q) || - /\bcomponent.*\b(all|every|have)\b/.test(q) || - /\blist\s+(everything|all)\b/.test(q) || - /\bshow\s+(everything|all)\b/.test(q) || - q === "components" || - q === "all" - ) { - const components = await getAllComponents(); - return formatComponentsList(components); - } - - // ------------------------------------------------------------------------- - // PATTERN: List components in a specific category - // "show components in buttons", "what's in ui", "list items in forms" - // ------------------------------------------------------------------------- - const categoryListPatterns = [ - /(?:show|list|get|what'?s?)\s+(?:components?|items?)?\s*(?:in|from|under)\s+['"]?([a-z0-9_-]+)['"]?/i, - /(?:in|from)\s+['"]?([a-z0-9_-]+)['"]?\s+(?:category|folder)/i, - /['"]?([a-z0-9_-]+)['"]?\s+(?:components?|category)/i, - ]; - - for (const pattern of categoryListPatterns) { - const match = q.match(pattern); - if (match) { - const categoryInput = match[1]; - const categoryName = await fuzzyFindCategory(categoryInput); - - if (!categoryName) { - const categories = await getAllCategories(); - const suggestions = categories.slice(0, 5).map(c => `"${c.name}"`).join(", "); - return `โŒ **Category "${categoryInput}" not found** - -Available categories: ${suggestions || "none"} - -๐Ÿ’ก *Try "list categories" to see all available categories*`; - } - - const components = await getComponentsByCategory(categoryName); - if (components === null) { - return `โŒ **Category "${categoryName}" not found**`; - } - return formatComponentsList(components, categoryName); - } - } - - // ------------------------------------------------------------------------- - // PATTERN: Read/show a specific component - // "read Button from ui", "show me the Card component", "get LoginForm from auth" - // ------------------------------------------------------------------------- - const readPatterns = [ - // "read X from Y", "get X from Y", "show X from Y" - /(?:read|show|get|open|fetch|view|display)\s+['"]?(.+?)['"]?\s+(?:from|in)\s+['"]?([a-z0-9_-]+)['"]?/i, - // "X component from Y" - /['"]?(.+?)['"]?\s+(?:component\s+)?(?:from|in)\s+['"]?([a-z0-9_-]+)['"]?/i, - ]; - - for (const pattern of readPatterns) { - const match = query.match(pattern); - if (match) { - const titleInput = match[1].trim().replace(/^(the|a|my)\s+/i, "").replace(/\s+component$/i, ""); - const categoryInput = match[2].trim(); - - const categoryName = await fuzzyFindCategory(categoryInput); - if (!categoryName) { - return `โŒ **Category "${categoryInput}" not found** - -๐Ÿ’ก *Try "list categories" to see available categories*`; - } - - // Try exact match first - let component = await getComponent(categoryName, titleInput); - - // If not found, try fuzzy match within the category - if (!component) { - const categoryComponents = await getComponentsByCategory(categoryName); - if (categoryComponents) { - const normalized = normalizeText(titleInput); - const fuzzyMatch = categoryComponents.find(c => - normalizeText(c.title).includes(normalized) || - normalized.includes(normalizeText(c.title)) - ); - if (fuzzyMatch) { - component = await getComponent(categoryName, fuzzyMatch.title); - } - } - } - - if (!component) { - const categoryComponents = await getComponentsByCategory(categoryName); - const suggestions = categoryComponents?.slice(0, 5).map(c => `"${c.title}"`).join(", "); - return `โŒ **Component "${titleInput}" not found in "${categoryName}"** - -Available in ${categoryName}: ${suggestions || "no components"} - -๐Ÿ’ก *Try "show components in ${categoryName}" to see all*`; - } - - return formatComponentDetail(component, categoryName); - } - } - - // ------------------------------------------------------------------------- - // PATTERN: Read component (without category specified) - // "show me the Button component", "get Card", "read LoginForm" - // ------------------------------------------------------------------------- - const simpleReadPatterns = [ - /(?:read|show|get|open|fetch|view|display)\s+(?:me\s+)?(?:the\s+)?['"]?(.+?)['"]?(?:\s+component)?$/i, - /^['"]?(.+?)['"]?\s+(?:code|source|component)$/i, - ]; - - for (const pattern of simpleReadPatterns) { - const match = query.match(pattern); - if (match) { - const titleInput = match[1].trim(); - - // Skip if it looks like a different command - if (/^(all|my|the|in|from|categories?|components?)$/i.test(titleInput)) continue; - - const foundComponent = await fuzzyFindComponent(titleInput); - - if (foundComponent) { - const categoryName = foundComponent.category?.name; - const component = await getComponent(categoryName, foundComponent.title); - return formatComponentDetail(component, categoryName); - } - - // Not found - search and suggest - const searchResults = await searchComponents(titleInput); - if (searchResults.length > 0) { - const suggestions = searchResults.slice(0, 5).map(c => - ` โ€ข **${c.title}** in *${c.category?.name}*` - ).join("\n"); - return `โ“ **Did you mean one of these?** - -${suggestions} - -๐Ÿ’ก *Be more specific: "read [component] from [category]"*`; - } - - return `โŒ **Component "${titleInput}" not found** - -๐Ÿ’ก *Try "list all components" or "search [keyword]"*`; - } - } - - // ------------------------------------------------------------------------- - // PATTERN: Search - // "find buttons", "search for cards", "look up forms" - // ------------------------------------------------------------------------- - const searchPatterns = [ - /(?:search|find|look\s*up|look\s*for)\s+(?:for\s+)?['"]?(.+?)['"]?$/i, - /^['"]?(.+?)['"]?\s+(?:search|find)$/i, - ]; - - for (const pattern of searchPatterns) { - const match = query.match(pattern); - if (match) { - const searchQuery = match[1].trim(); - const results = await searchComponents(searchQuery); - return formatSearchResults(results, searchQuery); - } - } - - // ------------------------------------------------------------------------- - // PATTERN: Help - // "help", "what can you do", "how do i use this" - // ------------------------------------------------------------------------- - if (/\b(help|usage|how|what can)\b/.test(q)) { - return `# ๐Ÿงฉ Composter - Your Component Vault - -I can help you manage your React component library. Here's what you can ask: - -## ๐Ÿ“ Categories - โ€ข *"list categories"* โ€” See all your categories - โ€ข *"show components in [category]"* โ€” Browse a category - -## ๐Ÿ“ฆ Components - โ€ข *"show all components"* โ€” List everything - โ€ข *"read [component] from [category]"* โ€” Get full source code - โ€ข *"find [keyword]"* โ€” Search your vault - -## ๐Ÿ’ก Examples - โ€ข "What categories do I have?" - โ€ข "Show me components in ui" - โ€ข "Read Button from buttons" - โ€ข "Find form components" - ---- - -**CLI Commands:** -\`\`\`bash -composter login # Authenticate -composter ls # List categories -composter push ui "Card" ./Card.jsx -composter pull ui "Card" ./components/ -\`\`\``; - } - - // ------------------------------------------------------------------------- - // FALLBACK: Treat as search query - // ------------------------------------------------------------------------- - const results = await searchComponents(query); - if (results.length > 0) { - return formatSearchResults(results, query); - } - - // Nothing found - provide guidance - const categories = await getAllCategories(); - const categoryList = categories.slice(0, 5).map(c => `"${c.name}"`).join(", "); - - return `๐Ÿค” **I'm not sure what you're looking for** - -I couldn't find anything matching "${query}". - -**Try asking:** - โ€ข "list categories" - โ€ข "show all components" - โ€ข "find [keyword]" -${categoryList ? `\n**Your categories:** ${categoryList}` : ""} - -๐Ÿ’ก *Type "help" for more guidance*`; -} - -// ============================================================================ -// MCP SERVER FACTORY -// ============================================================================ +import { registerTools } from "../src/tools/index.js"; /** * Creates and configures the Composter MCP server with all tools @@ -599,164 +21,8 @@ export function createMcpServer() { version: "2.0.0", }); - // =========================================================================== - // TOOL: ask_composter (Primary Natural Language Interface) - // =========================================================================== - server.tool( - "ask_composter", - `Ask in plain English to list categories, show components in a category, search components, or read a component (e.g., 'list categories', 'show components in ui', 'read Simple Card from ui', 'find button components').`, - { - query: z.string().describe( - "Natural language query - e.g., 'list categories', 'show components in buttons', 'read Card from ui', 'find forms'" - ), - }, - async ({ query }) => { - try { - const result = await processNaturalQuery(query.trim()); - return { content: [{ type: "text", text: result }] }; - } catch (err) { - return { - content: [{ - type: "text", - text: `โŒ **Error:** ${err.message}\n\n๐Ÿ’ก *Make sure you're logged in: \`composter login\`*` - }] - }; - } - } - ); - - // =========================================================================== - // TOOL: search_components - // =========================================================================== - server.tool( - "search_components", - "Search vault components by name or topic. Triggers on queries like 'find button components', 'search cards', 'look up forms'. Returns matches with IDs and categories.", - { - query: z.string().describe("Search term for component title or category name"), - }, - async ({ query }) => { - try { - const results = await searchComponents(query.trim()); - return { - content: [{ type: "text", text: formatSearchResults(results, query.trim()) }] - }; - } catch (err) { - return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; - } - } - ); - - // =========================================================================== - // TOOL: list_categories - // =========================================================================== - server.tool( - "list_categories", - "List all categories in the vault. Trigger when user asks 'what categories do I have', 'show my categories', 'list vault categories'.", - {}, - async () => { - try { - const categories = await getAllCategories(); - return { content: [{ type: "text", text: formatCategoriesList(categories) }] }; - } catch (err) { - return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; - } - } - ); - - // =========================================================================== - // TOOL: list_components - // =========================================================================== - server.tool( - "list_components", - "List components inside a given category. Trigger on requests like 'show components in ui', 'what's in forms', 'list items in buttons'.", - { - category: z.string().describe("The category name to list components from"), - }, - async ({ category }) => { - try { - const categoryName = await fuzzyFindCategory(category.trim()); - - if (!categoryName) { - const categories = await getAllCategories(); - const suggestions = categories.slice(0, 5).map(c => `"${c.name}"`).join(", "); - return { - content: [{ - type: "text", - text: `โŒ **Category "${category}" not found**\n\nAvailable: ${suggestions || "none"}` - }] - }; - } - - const components = await getComponentsByCategory(categoryName); - return { - content: [{ type: "text", text: formatComponentsList(components, categoryName) }] - }; - } catch (err) { - return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; - } - } - ); - - // =========================================================================== - // TOOL: read_component - // =========================================================================== - server.tool( - "read_component", - "Read a component's full source. Trigger on 'read/open/show/get from ' or similar. Returns code, category, dependencies, and creation date.", - { - category: z.string().describe("The category name the component belongs to"), - title: z.string().describe("The title/name of the component to read"), - }, - async ({ category, title }) => { - try { - const categoryName = await fuzzyFindCategory(category.trim()); - - if (!categoryName) { - return { - content: [{ - type: "text", - text: `โŒ **Category "${category}" not found**\n\n๐Ÿ’ก *Try "list categories" to see available options*` - }] - }; - } - - // Try exact match - let component = await getComponent(categoryName, title.trim()); - - // Try fuzzy match if exact fails - if (!component) { - const categoryComponents = await getComponentsByCategory(categoryName); - if (categoryComponents) { - const normalized = normalizeText(title); - const fuzzyMatch = categoryComponents.find(c => - normalizeText(c.title).includes(normalized) || - normalized.includes(normalizeText(c.title)) - ); - if (fuzzyMatch) { - component = await getComponent(categoryName, fuzzyMatch.title); - } - } - } - - if (!component) { - const categoryComponents = await getComponentsByCategory(categoryName); - const suggestions = categoryComponents?.slice(0, 5).map(c => `"${c.title}"`).join(", "); - return { - content: [{ - type: "text", - text: `โŒ **Component "${title}" not found in "${categoryName}"**\n\nAvailable: ${suggestions || "none"}` - }] - }; - } - - return { - content: [{ type: "text", text: formatComponentDetail(component, categoryName) }] - }; - } catch (err) { - return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; - } - } - ); + // Register all tools + registerTools(server); return server; } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index b25e19c..f33a724 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", - "zod": "^3.23.0" + "zod": "^4.3.5" }, "devDependencies": { "@modelcontextprotocol/inspector": "^0.18.0" diff --git a/packages/mcp/src/services/api.js b/packages/mcp/src/services/api.js new file mode 100644 index 0000000..a8609f6 --- /dev/null +++ b/packages/mcp/src/services/api.js @@ -0,0 +1,26 @@ +import { getAuthToken, getBaseUrl } from "../../lib/auth.js"; + +/** + * Makes authenticated API requests to the Composter backend + * @param {string} path - The API endpoint path + * @param {Object} options - The fetch options + * @returns response - The fetch response + */ +async function api(path, options = {}) { + const token = getAuthToken(); + const baseUrl = getBaseUrl(); + + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + ...options.headers, + }, + }); + + return response; +} + +export default api; + \ No newline at end of file diff --git a/packages/mcp/src/services/catalog.js b/packages/mcp/src/services/catalog.js new file mode 100644 index 0000000..d27d39b --- /dev/null +++ b/packages/mcp/src/services/catalog.js @@ -0,0 +1,55 @@ +import api from "./api.js"; + +async function getAllCategories() { + const res = await api("/categories"); + if (!res.ok) throw new Error(await getErrorMessage(res)); + const data = await res.json(); + return data.categories || []; +} + +async function getAllComponents() { + const res = await api("/components/list"); + if (!res.ok) throw new Error(await getErrorMessage(res)); + const data = await res.json(); + return data.components || []; +} + +async function getComponentsByCategory(categoryName) { + const res = await api(`/components/list-by-category?category=${encodeURIComponent(categoryName)}`); + if (res.status === 404) return null; // Category not found + if (!res.ok) throw new Error(await getErrorMessage(res)); + const data = await res.json(); + return data.components || []; +} + +async function getComponent(category, title) { + const res = await api(`/components?category=${encodeURIComponent(category)}&title=${encodeURIComponent(title)}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(await getErrorMessage(res)); + const data = await res.json(); + return data.component; +} + +async function searchComponents(query) { + const res = await api(`/components/search?q=${encodeURIComponent(query)}`); + if (!res.ok) throw new Error(await getErrorMessage(res)); + const data = await res.json(); + return data.components || []; +} + +async function getErrorMessage(res) { + try { + const data = await res.json(); + return data.message || data.error || res.statusText; + } catch { + return res.statusText; + } +} + +export { + getAllCategories, + getAllComponents, + getComponentsByCategory, + getComponent, + searchComponents, +}; diff --git a/packages/mcp/src/tools/components.js b/packages/mcp/src/tools/components.js new file mode 100644 index 0000000..e745f8c --- /dev/null +++ b/packages/mcp/src/tools/components.js @@ -0,0 +1,113 @@ +import { z } from "zod"; +import { + getComponentsByCategory, + getComponent, + searchComponents, + getAllCategories, +} from "../services/catalog.js"; +import { + formatComponentDetail, + formatSearchResults, + formatComponentsList, +} from "../utils/formatting.js"; +import { fuzzyFindCategory } from "../utils/fuzzy.js"; + +/** + * Register component-related tools + */ +export function registerComponentTools(server) { + server.registerTool( + "search_components", + { + title: "Search Components", + description: "Search vault components by name or topic.", + inputSchema: z.object({ + query: z.string().describe("Search term for component or category") + }) + }, + async ({ query }) => { + try { + const results = await searchComponents(query.trim()); + return { + content: [{ type: "text", text: formatSearchResults(results, query.trim()) }] + }; + } catch (err) { + return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; + } + } + ); + + server.registerTool( + "read_component", + { + title: "Read Component", + description: "Read a component's full source code and metadata.", + inputSchema: z.object({ + category: z.string().describe("The category name"), + title: z.string().describe("The title/name of the component") + }) + }, + async ({ category, title }) => { + try { + const categoryName = await fuzzyFindCategory(category.trim()); + if (!categoryName) { + return { content: [{ type: "text", text: `โŒ Category "${category}" not found.` }] }; + } + + let component = await getComponent(categoryName, title.trim()); + + // Fuzzy logic remains same + if (!component) { + const categoryComponents = await getComponentsByCategory(categoryName); + const normalized = title.toLowerCase(); + const fuzzyMatch = categoryComponents?.find(c => + c.title.toLowerCase().includes(normalized) + ); + if (fuzzyMatch) component = await getComponent(categoryName, fuzzyMatch.title); + } + + if (!component) { + return { content: [{ type: "text", text: `โŒ Component "${title}" not found.` }] }; + } + + return { content: [{ type: "text", text: formatComponentDetail(component, categoryName) }] }; + } catch (err) { + return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; + } + } + ); + + server.registerTool( + "list_components", + { + title: "List Components", + description: "List components inside a given category. Trigger on requests like 'show components in ui', 'what's in forms', 'list items in buttons'.", + inputSchema: z.object({ + category: z.string().describe("The category name to list components from") + }) + }, + async ({ category }) => { + try { + const categoryName = await fuzzyFindCategory(category.trim()); + + if (!categoryName) { + const categories = await getAllCategories(); + const suggestions = categories.slice(0, 5).map(c => `"${c.name}"`).join(", "); + return { + content: [{ + type: "text", + text: `โŒ **Category "${category}" not found**\n\nAvailable: ${suggestions || "none"}` + }] + }; + } + + const components = await getComponentsByCategory(categoryName); + return { + content: [{ type: "text", text: formatComponentsList(components, categoryName) }] + }; + } catch (err) { + return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; + } + } + ); +} \ No newline at end of file diff --git a/packages/mcp/src/tools/general.js b/packages/mcp/src/tools/general.js new file mode 100644 index 0000000..ec030fd --- /dev/null +++ b/packages/mcp/src/tools/general.js @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { processNaturalQuery } from "../utils/nlp.js"; +import { getAllCategories } from "../services/catalog.js"; +import { formatCategoriesList } from "../utils/formatting.js"; + +/** + * Register general tools related to vault management + */ +export function registerGeneralTools(server) { + server.registerTool( + "ask_composter", + { + title: "Ask Composter", + description: "Ask in plain English to manage vault components.", + inputSchema: z.object({ + query: z.string().describe("Natural language query") + }) + }, + async ({ query }) => { + try { + const result = await processNaturalQuery(query.trim()); + return { content: [{ type: "text", text: result }] }; + } catch (err) { + return { + content: [{ + type: "text", + text: `โŒ **Error:** ${err.message}\n\n๐Ÿ’ก *Make sure you're logged in: \`composter login\`*` + }] + }; + } + } + ); + + server.registerTool( + "list_categories", + { + title: "List Categories", + description: "List all categories in the vault.", + inputSchema: z.object({}) + }, + async () => { + try { + const categories = await getAllCategories(); + return { content: [{ type: "text", text: formatCategoriesList(categories) }] }; + } catch (err) { + return { content: [{ type: "text", text: `โŒ **Error:** ${err.message}` }] }; + } + } + ); +} \ No newline at end of file diff --git a/packages/mcp/src/tools/index.js b/packages/mcp/src/tools/index.js new file mode 100644 index 0000000..cadd2f4 --- /dev/null +++ b/packages/mcp/src/tools/index.js @@ -0,0 +1,11 @@ +import { registerGeneralTools } from "./general.js"; +import { registerComponentTools } from "./components.js"; + +/** + * Registers all tools to the provided MCP server instance + * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server - The Model Context Protocol server instance + */ +export function registerTools(server) { + registerGeneralTools(server); + registerComponentTools(server); +} \ No newline at end of file diff --git a/packages/mcp/src/utils/formatting.js b/packages/mcp/src/utils/formatting.js new file mode 100644 index 0000000..0415ad1 --- /dev/null +++ b/packages/mcp/src/utils/formatting.js @@ -0,0 +1,192 @@ +function getDepsCount(component) { + if (!component.dependencies) return 0; + try { + const deps = typeof component.dependencies === "string" + ? JSON.parse(component.dependencies) + : component.dependencies; + return Object.keys(deps).length; + } catch { + return 0; + } +} + +function getLanguageFromPath(filePath) { + const ext = filePath.split(".").pop()?.toLowerCase(); + const langMap = { + tsx: "tsx", + ts: "typescript", + jsx: "jsx", + js: "javascript", + css: "css", + scss: "scss", + json: "json", + md: "markdown" + }; + return langMap[ext] || "tsx"; +} + +function formatCategoriesList(categories) { + if (!categories.length) { + return `๐Ÿ“ **No categories found** + +Your vault is empty! Get started by creating a category: + +\`\`\`bash +composter mkcat buttons +\`\`\` + +Then push your first component: + +\`\`\`bash +composter push buttons "MyButton" ./src/components/Button.jsx +\`\`\``; + } + + const list = categories.map(c => ` โ€ข **${c.name}**`).join("\n"); + return `๐Ÿ“ **Your Categories** (${categories.length}) + +${list} + +๐Ÿ’ก *Ask me to "show components in [category]" to explore further*`; +} + +function formatComponentsList(components, categoryName = null) { + if (!components.length) { + const context = categoryName ? ` in "${categoryName}"` : ""; + return `๐Ÿ“ฆ **No components found${context}** + +Push components using the CLI: + +\`\`\`bash +composter push ${categoryName || "category"} "ComponentName" ./path/to/component.jsx +\`\`\``; + } + + const header = categoryName + ? `๐Ÿ“ฆ **Components in "${categoryName}"** (${components.length})` + : `๐Ÿ“ฆ **All Components** (${components.length})`; + + const list = components.map(c => { + const category = c.category?.name || "uncategorized"; + const date = new Date(c.createdAt).toLocaleDateString(); + const deps = getDepsCount(c); + const depsLabel = deps > 0 ? ` ยท ${deps} deps` : ""; + + return categoryName + ? ` โ€ข **${c.title}** โ€” ${date}${depsLabel}` + : ` โ€ข **${c.title}** *(${category})* โ€” ${date}${depsLabel}`; + }).join("\n"); + + return `${header} + +${list} + +๐Ÿ’ก *Ask me to "read [component] from [category]" to see the code*`; +} + +function formatComponentDetail(component, categoryName) { + if (!component) { + return `โŒ **Component not found** + +Try searching: *"find [keyword]"*`; + } + + // Parse multi-file or single-file code + let codeBlocks = ""; + try { + const files = JSON.parse(component.code); + codeBlocks = Object.entries(files) + .map(([filePath, content]) => { + const lang = getLanguageFromPath(filePath); + return `### ๐Ÿ“„ \`${filePath}\` + +\`\`\`${lang} +${content} +\`\`\``; + }) + .join("\n\n"); + } catch { + // Single file component + codeBlocks = `\`\`\`tsx +${component.code} +\`\`\``; + } + + // Format dependencies + let depsSection = ""; + if (component.dependencies && Object.keys(component.dependencies).length > 0) { + const deps = Object.entries(component.dependencies) + .map(([pkg, ver]) => ` โ€ข \`${pkg}\`: ${ver}`) + .join("\n"); + + const installCmd = Object.keys(component.dependencies).join(" "); + depsSection = ` +--- + +### ๐Ÿ“ฆ Dependencies + +${deps} + +**Install command:** +\`\`\`bash +npm install ${installCmd} +\`\`\` +`; + } + + const createdDate = new Date(component.createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + + return `# ${component.title} + +| Property | Value | +|----------|-------| +| **Category** | ${categoryName} | +| **Created** | ${createdDate} | + +--- + +## Source Code + +${codeBlocks} +${depsSection} +--- + +๐Ÿ’ก *Pull this component:* \`composter pull ${categoryName} "${component.title}" ./components/\``; +} + +function formatSearchResults(components, query) { + if (!components.length) { + return `๐Ÿ” **No results for "${query}"** + +Try: + โ€ข Different keywords + โ€ข *"list categories"* to see what's available + โ€ข *"show all components"* to browse everything`; + } + + const results = components.slice(0, 10).map(c => { + const category = c.category?.name || "uncategorized"; + return ` โ€ข **${c.title}** in *${category}*`; + }).join("\n"); + + const moreNote = components.length > 10 + ? `\n\n*...and ${components.length - 10} more results*` + : ""; + + return `๐Ÿ” **Search results for "${query}"** (${components.length}) + +${results}${moreNote} + +๐Ÿ’ก *Ask me to "read [component] from [category]" to see the full code*`; +} + +export { + formatCategoriesList, + formatComponentsList, + formatComponentDetail, + formatSearchResults, +} \ No newline at end of file diff --git a/packages/mcp/src/utils/fuzzy.js b/packages/mcp/src/utils/fuzzy.js new file mode 100644 index 0000000..0d6ccfa --- /dev/null +++ b/packages/mcp/src/utils/fuzzy.js @@ -0,0 +1,55 @@ +import { getAllComponents, getAllCategories } from "../services/catalog.js" + +export function normalizeText(text) { + return text.toLowerCase().trim().replace(/\s+/g, " "); +} + +/** + * Fuzzy match a component by title across all components + */ +export async function fuzzyFindComponent(searchTitle) { + const allComponents = await getAllComponents(); + const normalized = normalizeText(searchTitle); + + // Exact match first + let match = allComponents.find(c => + normalizeText(c.title) === normalized + ); + if (match) return match; + + // Partial match + match = allComponents.find(c => + normalizeText(c.title).includes(normalized) || + normalized.includes(normalizeText(c.title)) + ); + if (match) return match; + + // Word-based match + const searchWords = normalized.split(/\s+/); + match = allComponents.find(c => { + const titleNorm = normalizeText(c.title); + return searchWords.every(word => titleNorm.includes(word)); + }); + + return match; +} + +/** + * Fuzzy match a category by name + */ +export async function fuzzyFindCategory(searchName) { + const categories = await getAllCategories(); + const normalized = normalizeText(searchName); + + // Exact match + let match = categories.find(c => normalizeText(c.name) === normalized); + if (match) return match.name; + + // Partial match + match = categories.find(c => + normalizeText(c.name).includes(normalized) || + normalized.includes(normalizeText(c.name)) + ); + + return match?.name || null; +} diff --git a/packages/mcp/src/utils/nlp.js b/packages/mcp/src/utils/nlp.js new file mode 100644 index 0000000..6d8b0fd --- /dev/null +++ b/packages/mcp/src/utils/nlp.js @@ -0,0 +1,235 @@ +import { + normalizeText, + fuzzyFindCategory, + fuzzyFindComponent +} from "./fuzzy.js"; +import { + getAllCategories, + getAllComponents, + getComponentsByCategory, + getComponent, + searchComponents, +} from "../services/catalog.js"; +import { + formatCategoriesList, + formatComponentsList, + formatComponentDetail, + formatSearchResults, +} from "./formatting.js" +import { + categoryListPatterns, + readPatterns, + simpleReadPatterns, + searchPatterns, +} from "./regexPatterns.js" + +/** + * Intelligent query parser that understands natural language requests + * and routes them to the appropriate handler + */ +export async function processNaturalQuery(query) { + const q = normalizeText(query); + + // ------------------------------------------------------------------------- + // PATTERN: List all categories + // "list categories", "show my categories", "what categories do i have" + // ------------------------------------------------------------------------- + if ( + /\b(list|show|get|what|my)\b.*\bcategor(y|ies)\b/.test(q) || + /\bcategor(y|ies)\b.*\b(list|show|have)\b/.test(q) || + q === "categories" + ) { + const categories = await getAllCategories(); + return formatCategoriesList(categories); + } + + // ------------------------------------------------------------------------- + // PATTERN: List all components + // "show all components", "list everything", "what components do i have" + // ------------------------------------------------------------------------- + if ( + /\b(all|every)\b.*\bcomponent/.test(q) || + /\bcomponent.*\b(all|every|have)\b/.test(q) || + /\blist\s+(everything|all)\b/.test(q) || + /\bshow\s+(everything|all)\b/.test(q) || + q === "components" || + q === "all" + ) { + const components = await getAllComponents(); + return formatComponentsList(components); + } + + for (const pattern of categoryListPatterns) { + const match = q.match(pattern); + if (match) { + const categoryInput = match[1]; + const categoryName = await fuzzyFindCategory(categoryInput); + + if (!categoryName) { + const categories = await getAllCategories(); + const suggestions = categories.slice(0, 5).map(c => `"${c.name}"`).join(", "); + return `โŒ **Category "${categoryInput}" not found** + +Available categories: ${suggestions || "none"} + +๐Ÿ’ก *Try "list categories" to see all available categories*`; + } + + const components = await getComponentsByCategory(categoryName); + if (components === null) { + return `โŒ **Category "${categoryName}" not found**`; + } + return formatComponentsList(components, categoryName); + } + } + + + + for (const pattern of readPatterns) { + const match = query.match(pattern); + if (match) { + const titleInput = match[1].trim().replace(/^(the|a|my)\s+/i, "").replace(/\s+component$/i, ""); + const categoryInput = match[2].trim(); + + const categoryName = await fuzzyFindCategory(categoryInput); + if (!categoryName) { + return `โŒ **Category "${categoryInput}" not found** + +๐Ÿ’ก *Try "list categories" to see available categories*`; + } + + // Try exact match first + let component = await getComponent(categoryName, titleInput); + + // If not found, try fuzzy match within the category + if (!component) { + const categoryComponents = await getComponentsByCategory(categoryName); + if (categoryComponents) { + const normalized = normalizeText(titleInput); + const fuzzyMatch = categoryComponents.find(c => + normalizeText(c.title).includes(normalized) || + normalized.includes(normalizeText(c.title)) + ); + if (fuzzyMatch) { + component = await getComponent(categoryName, fuzzyMatch.title); + } + } + } + + if (!component) { + const categoryComponents = await getComponentsByCategory(categoryName); + const suggestions = categoryComponents?.slice(0, 5).map(c => `"${c.title}"`).join(", "); + return `โŒ **Component "${titleInput}" not found in "${categoryName}"** + +Available in ${categoryName}: ${suggestions || "no components"} + +๐Ÿ’ก *Try "show components in ${categoryName}" to see all*`; + } + + return formatComponentDetail(component, categoryName); + } + } + + for (const pattern of simpleReadPatterns) { + const match = query.match(pattern); + if (match) { + const titleInput = match[1].trim(); + + // Skip if it looks like a different command + if (/^(all|my|the|in|from|categories?|components?)$/i.test(titleInput)) continue; + + const foundComponent = await fuzzyFindComponent(titleInput); + + if (foundComponent) { + const categoryName = foundComponent.category?.name; + const component = await getComponent(categoryName, foundComponent.title); + return formatComponentDetail(component, categoryName); + } + + // Not found - search and suggest + const searchResults = await searchComponents(titleInput); + if (searchResults.length > 0) { + const suggestions = searchResults.slice(0, 5).map(c => + ` โ€ข **${c.title}** in *${c.category?.name}*` + ).join("\n"); + return `โ“ **Did you mean one of these?** + +${suggestions} + +๐Ÿ’ก *Be more specific: "read [component] from [category]"*`; + } + + return `โŒ **Component "${titleInput}" not found** + +๐Ÿ’ก *Try "list all components" or "search [keyword]"*`; + } + } + + for (const pattern of searchPatterns) { + const match = query.match(pattern); + if (match) { + const searchQuery = match[1].trim(); + const results = await searchComponents(searchQuery); + return formatSearchResults(results, searchQuery); + } + } + + // ------------------------------------------------------------------------- + // PATTERN: Help + // "help", "what can you do", "how do i use this" + // ------------------------------------------------------------------------- + if (/\b(help|usage|how|what can)\b/.test(q)) { + return `# ๐Ÿงฉ Composter - Your Component Vault + +I can help you manage your React component library. Here's what you can ask: + +## ๐Ÿ“ Categories + โ€ข *"list categories"* โ€” See all your categories + โ€ข *"show components in [category]"* โ€” Browse a category + +## ๐Ÿ“ฆ Components + โ€ข *"show all components"* โ€” List everything + โ€ข *"read [component] from [category]"* โ€” Get full source code + โ€ข *"find [keyword]"* โ€” Search your vault + +## ๐Ÿ’ก Examples + โ€ข "What categories do I have?" + โ€ข "Show me components in ui" + โ€ข "Read Button from buttons" + โ€ข "Find form components" + +--- + +**CLI Commands:** +\`\`\`bash +composter login # Authenticate +composter ls # List categories +composter push ui "Card" ./Card.jsx +composter pull ui "Card" ./components/ +\`\`\``; + } + + // ------------------------------------------------------------------------- + // FALLBACK: Treat as search query + // ------------------------------------------------------------------------- + const results = await searchComponents(query); + if (results.length > 0) { + return formatSearchResults(results, query); + } + + // Nothing found - provide guidance + const categories = await getAllCategories(); + const categoryList = categories.slice(0, 5).map(c => `"${c.name}"`).join(", "); + + return `๐Ÿค” **I'm not sure what you're looking for** + +I couldn't find anything matching "${query}". + +**Try asking:** + โ€ข "list categories" + โ€ข "show all components" + โ€ข "find [keyword]" +${categoryList ? `\n**Your categories:** ${categoryList}` : ""} + +๐Ÿ’ก *Type "help" for more guidance*`; +} \ No newline at end of file diff --git a/packages/mcp/src/utils/regexPatterns.js b/packages/mcp/src/utils/regexPatterns.js new file mode 100644 index 0000000..f54d28b --- /dev/null +++ b/packages/mcp/src/utils/regexPatterns.js @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------- +// PATTERN: List components in a specific category +// "show components in buttons", "what's in ui", "list items in forms" +// ------------------------------------------------------------------------- +const categoryListPatterns = [ + /(?:show|list|get|what'?s?)\s+(?:components?|items?)?\s*(?:in|from|under)\s+['"]?([a-z0-9_-]+)['"]?/i, + /(?:in|from)\s+['"]?([a-z0-9_-]+)['"]?\s+(?:category|folder)/i, + /['"]?([a-z0-9_-]+)['"]?\s+(?:components?|category)/i, +]; + +// ------------------------------------------------------------------------- +// PATTERN: Read/show a specific component +// "read Button from ui", "show me the Card component", "get LoginForm from auth" +// ------------------------------------------------------------------------- +const readPatterns = [ + // "read X from Y", "get X from Y", "show X from Y" + /(?:read|show|get|open|fetch|view|display)\s+['"]?(.+?)['"]?\s+(?:from|in)\s+['"]?([a-z0-9_-]+)['"]?/i, + // "X component from Y" + /['"]?(.+?)['"]?\s+(?:component\s+)?(?:from|in)\s+['"]?([a-z0-9_-]+)['"]?/i, +]; + + +// ------------------------------------------------------------------------- +// PATTERN: Read component (without category specified) +// "show me the Button component", "get Card", "read LoginForm" +// ------------------------------------------------------------------------- +const simpleReadPatterns = [ + /(?:read|show|get|open|fetch|view|display)\s+(?:me\s+)?(?:the\s+)?['"]?(.+?)['"]?(?:\s+component)?$/i, + /^['"]?(.+?)['"]?\s+(?:code|source|component)$/i, +]; + +// ------------------------------------------------------------------------- +// PATTERN: Search +// "find buttons", "search for cards", "look up forms" +// ------------------------------------------------------------------------- +const searchPatterns = [ + /(?:search|find|look\s*up|look\s*for)\s+(?:for\s+)?['"]?(.+?)['"]?$/i, + /^['"]?(.+?)['"]?\s+(?:search|find)$/i, +]; + +export { + categoryListPatterns, + readPatterns, + simpleReadPatterns, + searchPatterns, +} \ No newline at end of file