diff --git a/packages/ghost-mcp/package.json b/packages/ghost-mcp/package.json new file mode 100644 index 0000000..485818c --- /dev/null +++ b/packages/ghost-mcp/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ghost/mcp", + "version": "0.1.0", + "description": "MCP server for the Ghost UI component registry", + "license": "Apache-2.0", + "type": "module", + "bin": { + "ghost-mcp": "./dist/bin.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build && node scripts/copy-assets.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.25.0" + } +} diff --git a/packages/ghost-mcp/scripts/copy-assets.mjs b/packages/ghost-mcp/scripts/copy-assets.mjs new file mode 100644 index 0000000..41df1e9 --- /dev/null +++ b/packages/ghost-mcp/scripts/copy-assets.mjs @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const assetsDir = path.resolve(__dirname, "../dist/assets"); + +fs.mkdirSync(assetsDir, { recursive: true }); + +fs.copyFileSync( + path.resolve(__dirname, "../../ghost-ui/registry.json"), + path.join(assetsDir, "registry.json"), +); + +fs.copyFileSync( + path.resolve(__dirname, "../../ghost-ui/.shadcn/skills.md"), + path.join(assetsDir, "skills.md"), +); diff --git a/packages/ghost-mcp/src/bin.ts b/packages/ghost-mcp/src/bin.ts new file mode 100644 index 0000000..c20c22c --- /dev/null +++ b/packages/ghost-mcp/src/bin.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; + +const server = createServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/ghost-mcp/src/data.ts b/packages/ghost-mcp/src/data.ts new file mode 100644 index 0000000..e34a4dd --- /dev/null +++ b/packages/ghost-mcp/src/data.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const assetsDir = path.resolve(__dirname, "assets"); + +export interface RegistryFile { + type: string; + target: string; + path: string; +} + +export interface RegistryCssVars { + theme?: Record; + light?: Record; + dark?: Record; +} + +export interface RegistryItem { + name: string; + type: string; + title?: string; + description?: string; + author?: string; + style?: string; + iconLibrary?: string; + baseColor?: string; + dependencies?: string[]; + devDependencies?: string[]; + registryDependencies?: string[]; + files: RegistryFile[]; + categories?: string[]; + cssVars?: RegistryCssVars; +} + +export interface Registry { + $schema: string; + name: string; + homepage: string; + items: RegistryItem[]; +} + +const registryRaw = fs.readFileSync( + path.join(assetsDir, "registry.json"), + "utf-8", +); +const registry: Registry = JSON.parse(registryRaw); + +const skillsContent = fs.readFileSync( + path.join(assetsDir, "skills.md"), + "utf-8", +); + +const itemsByName = new Map(); +for (const item of registry.items) { + itemsByName.set(item.name, item); +} + +export interface ItemSummary { + name: string; + type: string; + categories: string[]; + dependencies: string[]; + registryDependencies: string[]; + description?: string; +} + +export function searchItems( + query?: string, + category?: string, + aiOnly?: boolean, +): ItemSummary[] { + let items = registry.items; + + if (category) { + const lower = category.toLowerCase(); + items = items.filter((i) => + (i.categories ?? []).some((c) => c.toLowerCase() === lower), + ); + } + + if (aiOnly) { + items = items.filter((i) => (i.categories ?? []).includes("ai")); + } + + if (query) { + const lower = query.toLowerCase(); + items = items.filter((i) => i.name.toLowerCase().includes(lower)); + } + + return items.map((i) => ({ + name: i.name, + type: i.type, + categories: i.categories ?? [], + dependencies: i.dependencies ?? [], + registryDependencies: i.registryDependencies ?? [], + description: i.description, + })); +} + +export function getRegistryItem(name: string): RegistryItem | undefined { + return itemsByName.get(name); +} + +export function getCategoriesWithCounts(): Record< + string, + { name: string; count: number } +> { + const result: Record = {}; + for (const item of registry.items) { + for (const cat of item.categories ?? []) { + if (result[cat]) { + result[cat].count++; + } else { + result[cat] = { name: cat, count: 1 }; + } + } + } + return result; +} + +export function getThemePreset(name: string): RegistryCssVars | undefined { + const item = registry.items.find( + (i) => i.type === "registry:theme" && i.name === name, + ); + return item?.cssVars; +} + +export function getComponentSource(name: string): string | null { + const item = itemsByName.get(name); + if (!item || item.files.length === 0) return null; + + const filePath = item.files[0].path; + const ghostUiDir = path.resolve(__dirname, "../../ghost-ui"); + const fullPath = path.join(ghostUiDir, filePath); + + try { + return fs.readFileSync(fullPath, "utf-8"); + } catch { + return null; + } +} + +export function getSkills(): string { + return skillsContent; +} diff --git a/packages/ghost-mcp/src/index.ts b/packages/ghost-mcp/src/index.ts new file mode 100644 index 0000000..0771695 --- /dev/null +++ b/packages/ghost-mcp/src/index.ts @@ -0,0 +1 @@ +export { createServer } from "./server.js"; diff --git a/packages/ghost-mcp/src/resources.ts b/packages/ghost-mcp/src/resources.ts new file mode 100644 index 0000000..fae534b --- /dev/null +++ b/packages/ghost-mcp/src/resources.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { getSkills } from "./data.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const assetsDir = path.resolve(__dirname, "assets"); + +export function registerResources(server: McpServer): void { + server.resource( + "Ghost UI Registry", + "ghost://registry", + { mimeType: "application/json" }, + async () => { + const content = fs.readFileSync( + path.join(assetsDir, "registry.json"), + "utf-8", + ); + return { + contents: [ + { + uri: "ghost://registry", + mimeType: "application/json", + text: content, + }, + ], + }; + }, + ); + + server.resource( + "Ghost UI Skills", + "ghost://skills", + { mimeType: "text/markdown" }, + async () => { + const content = getSkills(); + return { + contents: [ + { + uri: "ghost://skills", + mimeType: "text/markdown", + text: content, + }, + ], + }; + }, + ); +} diff --git a/packages/ghost-mcp/src/server.ts b/packages/ghost-mcp/src/server.ts new file mode 100644 index 0000000..72438f1 --- /dev/null +++ b/packages/ghost-mcp/src/server.ts @@ -0,0 +1,15 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerResources } from "./resources.js"; +import { registerTools } from "./tools.js"; + +export function createServer(): McpServer { + const server = new McpServer({ + name: "ghost-ui", + version: "0.1.0", + }); + + registerTools(server); + registerResources(server); + + return server; +} diff --git a/packages/ghost-mcp/src/tools.ts b/packages/ghost-mcp/src/tools.ts new file mode 100644 index 0000000..f070436 --- /dev/null +++ b/packages/ghost-mcp/src/tools.ts @@ -0,0 +1,137 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + getCategoriesWithCounts, + getComponentSource, + getRegistryItem, + getThemePreset, + searchItems, +} from "./data.js"; + +const REGISTRY_URL = "https://block.github.io/ghost/r/registry.json"; + +export function registerTools(server: McpServer): void { + server.tool( + "search_components", + "Search Ghost UI components by name, category, or AI filter", + { + query: z + .string() + .optional() + .describe("Substring to match against component names"), + category: z + .string() + .optional() + .describe("Filter by category (e.g. input, layout, ai)"), + aiOnly: z + .boolean() + .optional() + .describe("Only return AI-category components"), + }, + async ({ query, category, aiOnly }) => { + const results = searchItems(query, category, aiOnly); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(results, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "get_component", + "Get full metadata and source code for a Ghost UI component", + { + name: z.string().describe("Component name from the registry"), + }, + async ({ name }) => { + const item = getRegistryItem(name); + if (!item) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ error: `Component "${name}" not found` }), + }, + ], + }; + } + const source = getComponentSource(name); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ ...item, source }, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "get_install_command", + "Generate a shadcn install command for Ghost UI components", + { + components: z + .array(z.string()) + .describe("Array of component names to install"), + }, + async ({ components }) => { + const cmd = `npx shadcn@latest add --registry ${REGISTRY_URL} ${components.join(" ")}`; + return { + content: [{ type: "text" as const, text: cmd }], + }; + }, + ); + + server.tool( + "list_categories", + "List all Ghost UI component categories with counts", + {}, + async () => { + const categories = getCategoriesWithCounts(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(categories, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "get_theme", + "Get CSS variables for a Ghost UI theme preset", + { + name: z.string().describe("Theme preset name"), + }, + async ({ name }) => { + const cssVars = getThemePreset(name); + if (!cssVars) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: `Theme "${name}" not found`, + }), + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(cssVars, null, 2), + }, + ], + }; + }, + ); +} diff --git a/packages/ghost-mcp/tsconfig.json b/packages/ghost-mcp/tsconfig.json new file mode 100644 index 0000000..f724352 --- /dev/null +++ b/packages/ghost-mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 107cd76..3f5c781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,15 @@ importers: specifier: ^1.50.0 version: 1.59.1 + packages/ghost-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) + zod: + specifier: ^3.25.0 + version: 3.25.76 + packages/ghost-ui: dependencies: '@hookform/resolvers': diff --git a/tsconfig.json b/tsconfig.json index f3e398f..3cc3704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "files": [], "references": [ { "path": "packages/ghost-core" }, - { "path": "packages/ghost-cli" } + { "path": "packages/ghost-cli" }, + { "path": "packages/ghost-mcp" } ] }